import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, isProjectResourcePreviewCancellation, } from '../../services/projectResourcePreviewTransport'; import { IDLE_PROJECT_RESOURCE_CARD_PREVIEW, PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY, PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, projectResourceCardPreviewEvictionIdentities, projectResourceCardPreviewIdentity, projectResourceCardPreviewImageDimensions, type ProjectResourceCardPreviewKind, projectResourceCardPreviewKind, type ProjectResourceCardPreviewPayload, type ProjectResourceCardPreviewState, type ProjectResourceCardPreviewTransportPayload, projectResourceMediaPreviewCategory, } from './resourceCardPreviewModel'; import type { ProjectResource } from './resourceProjectionModel'; type PreviewRequestReason = 'visible' | 'detail' | 'play'; type PreviewJob = { scopeKey: string; scopeId: string; scopeEpoch: number; identity: string; resource: ProjectResource; reason: PreviewRequestReason; }; type MaterializedPreview = { preview: ProjectResourceCardPreviewPayload; retainedBytes: number; objectUrl: string | null; }; type CachedPreview = { retainedBytes: number; objectUrl: string | null; }; type ObservedPreviewCard = { identity: string; resource: ProjectResource; }; function resourceReadKindLabel( resource: ProjectResource, kind: ProjectResourceCardPreviewKind, ) { if (kind === 'document') { return '文档'; } if (kind === 'code') { return '游戏代码'; } return '资源'; } function previewReadErrorMessage( resource: ProjectResource, kind: ProjectResourceCardPreviewKind, error: unknown, ): { error: string; retryable: boolean } { const message = error instanceof Error ? error.message : String(error); const kindLabel = resourceReadKindLabel(resource, kind); if (message.includes('项目权限策略要求用户确认')) { return { error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`, retryable: true, }; } if (message.includes('项目权限策略拒绝执行')) { return { error: `当前项目策略不允许读取${kindLabel},调整策略后请关闭详情并重试`, retryable: true, }; } if (message.includes('发生漂移') || message.includes('发生替换')) { return { error: '资源读取期间发生变化,请关闭详情并重试', retryable: true, }; } if ( message.includes('不能超过') || message.includes('UTF-8') || message.includes('只支持') || message.includes('脚本或外部资源引用') || message.includes('图片尺寸过大') || message.includes('图片尺寸溢出') || message.includes('图片结构无效') || message.includes('内容无效') || message.includes('文件为空') || message.includes('图片不能为空') || message.includes('签名与登记类型不一致') ) { return { error: message .replace('image.inspect', '图片预览') .replace(',暂时无法在客户端预览', ',无法在客户端预览'), retryable: false, }; } return { error: '资源暂时无法读取,请关闭详情并重试', retryable: true, }; } const previewRequestPriority: Record = { visible: 0, detail: 1, play: 2, }; function materializeProjectResourceCardPreview( transport: ProjectResourceCardPreviewTransportPayload, ): MaterializedPreview { if (!transport.dataUrl) { return { preview: { path: transport.path, mediaType: transport.mediaType, byteLen: transport.byteLen, pixelWidth: transport.pixelWidth, pixelHeight: transport.pixelHeight, content: transport.content, }, retainedBytes: transport.content === undefined ? 0 : new TextEncoder().encode(transport.content).byteLength, objectUrl: null, }; } const imageDimensions = projectResourceCardPreviewImageDimensions(transport); const prefix = `data:${transport.mediaType};base64,`; if (!transport.dataUrl.startsWith(prefix)) { throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致'); } let decoded: string; try { decoded = window.atob(transport.dataUrl.slice(prefix.length)); } catch { throw new Error('媒体预览内容无效:base64 解码失败'); } const bytes = new Uint8Array(decoded.length); for (let index = 0; index < decoded.length; index += 1) { bytes[index] = decoded.charCodeAt(index); } const blob = new Blob([bytes], { type: transport.mediaType }); if (blob.size > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT) { throw new Error('媒体预览不能超过客户端缓存字节预算'); } const objectUrl = URL.createObjectURL(blob); return { preview: { path: transport.path, mediaType: transport.mediaType, byteLen: transport.byteLen, pixelWidth: imageDimensions?.pixelWidth, pixelHeight: imageDimensions?.pixelHeight, sourceUrl: objectUrl, }, retainedBytes: blob.size, objectUrl, }; } function nextPreviewJob(queue: PreviewJob[]) { let nextIndex = 0; for (let index = 1; index < queue.length; index += 1) { if ( previewRequestPriority[queue[index]!.reason] > previewRequestPriority[queue[nextIndex]!.reason] ) { nextIndex = index; } } return queue.splice(nextIndex, 1)[0]; } export function useProjectResourceCardPreviews(input: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; resources: ProjectResource[]; canvasRef: React.RefObject; // 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器; // 缺省回退到 canvasRef。 intersectionRootRef?: React.RefObject; eagerPreviewLimit?: number; previewVersionByResourceId?: ReadonlyMap; }) { const scopeKey = JSON.stringify([input.projectPath, input.projectId]); const identityByResourceId = useMemo( () => new Map( input.resources.map((resource) => [ resource.id, projectResourceCardPreviewIdentity({ projectPath: input.projectPath, projectId: input.projectId, previewVersion: input.previewVersionByResourceId?.get(resource.id) ?? '', resource, }), ]), ), [ input.previewVersionByResourceId, input.projectId, input.projectPath, input.resources, ], ); const [previews, setPreviews] = useState< Map >(new Map()); const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState< Map< string, NonNullable> > >(new Map()); const [initialScopeId] = useState(createProjectResourcePreviewScopeId); const previewsRef = useRef(previews); const imageDimensionsByIdentityRef = useRef(imageDimensionsByIdentity); const scopeKeyRef = useRef(scopeKey); const scopeIdRef = useRef(initialScopeId); const scopeInitializedRef = useRef(false); const scopeEpochRef = useRef(0); const identityByResourceIdRef = useRef(identityByResourceId); const validIdentitiesRef = useRef(new Set(identityByResourceId.values())); const observerRef = useRef(null); const observedCardsRef = useRef(new Map()); const queueRef = useRef([]); const queuedIdentitiesRef = useRef(new Map()); const pendingIdentitiesRef = useRef(new Map()); const cacheOrderRef = useRef(new Map()); const cachedPreviewsRef = useRef(new Map()); const cacheBytesRef = useRef(0); const activeReadsRef = useRef({ scopeEpoch: 0, count: 0 }); const protectedIdentityRef = useRef(null); const drainQueueRef = useRef<() => void>(() => undefined); scopeKeyRef.current = scopeKey; identityByResourceIdRef.current = identityByResourceId; validIdentitiesRef.current = new Set(identityByResourceId.values()); const isCurrentJob = useCallback((job: PreviewJob) => { return ( scopeEpochRef.current === job.scopeEpoch && scopeKeyRef.current === job.scopeKey && scopeIdRef.current === job.scopeId && identityByResourceIdRef.current.get(job.resource.id) === job.identity ); }, []); const disposeCachedPreview = useCallback((identity: string) => { const cached = cachedPreviewsRef.current.get(identity); if (!cached) { return; } cachedPreviewsRef.current.delete(identity); cacheBytesRef.current = Math.max( 0, cacheBytesRef.current - cached.retainedBytes, ); if (cached.objectUrl) { URL.revokeObjectURL(cached.objectUrl); } }, []); const disposeAllCachedPreviews = useCallback(() => { for (const identity of Array.from(cachedPreviewsRef.current.keys())) { disposeCachedPreview(identity); } cacheOrderRef.current.clear(); cacheBytesRef.current = 0; }, [disposeCachedPreview]); const publishPreview = useCallback( ( identity: string, state: ProjectResourceCardPreviewState, materialized?: MaterializedPreview, ) => { const next = new Map(previewsRef.current); if (state.status === 'loaded') { const dimensions = projectResourceCardPreviewImageDimensions( state.preview, ); if (dimensions) { const nextImageDimensions = new Map( imageDimensionsByIdentityRef.current, ); nextImageDimensions.set(identity, dimensions); imageDimensionsByIdentityRef.current = nextImageDimensions; setImageDimensionsByIdentity(nextImageDimensions); } } cacheOrderRef.current.delete(identity); disposeCachedPreview(identity); next.set(identity, state); if (state.status === 'loaded' || state.status === 'failed') { const cached = materialized ? { retainedBytes: materialized.retainedBytes, objectUrl: materialized.objectUrl, } : { retainedBytes: 0, objectUrl: null }; cachedPreviewsRef.current.set(identity, cached); cacheBytesRef.current += cached.retainedBytes; cacheOrderRef.current.set(identity, true); const evictedIdentities = projectResourceCardPreviewEvictionIdentities( Array.from(cacheOrderRef.current.keys()).map((cachedIdentity) => ({ identity: cachedIdentity, retainedBytes: cachedPreviewsRef.current.get(cachedIdentity)?.retainedBytes ?? 0, })), protectedIdentityRef.current, ); for (const evictedIdentity of evictedIdentities) { cacheOrderRef.current.delete(evictedIdentity); disposeCachedPreview(evictedIdentity); next.delete(evictedIdentity); } } previewsRef.current = next; setPreviews(next); }, [disposeCachedPreview], ); const readPreview = useCallback( async ( job: PreviewJob, ): Promise => { const kind = projectResourceCardPreviewKind(job.resource); if ( (kind === 'document' || kind === 'code') && job.resource.content !== undefined ) { return { path: job.resource.path, mediaType: job.resource.mediaType, byteLen: new TextEncoder().encode(job.resource.content).byteLength, content: job.resource.content, }; } const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error( kind === 'document' || kind === 'code' ? '文档预览需要在客户端内打开' : '媒体预览需要在客户端内打开', ); } if (kind === 'raster-image') { return invoke( 'read_local_project_image_preview', { projectPath: input.projectPath, relativePath: job.resource.path, scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, ); } if (kind === 'document' || kind === 'code') { return invoke( 'read_local_project_text_preview', { projectPath: input.projectPath, relativePath: job.resource.path, scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, ); } return invoke( 'read_local_project_media_preview', { projectPath: input.projectPath, relativePath: job.resource.path, category: projectResourceMediaPreviewCategory(job.resource), scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, ); }, [input.projectPath], ); const drainQueue = useCallback(() => { while ( activeReadsRef.current.scopeEpoch === scopeEpochRef.current && activeReadsRef.current.count < PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY && queueRef.current.length > 0 ) { const job = nextPreviewJob(queueRef.current); if (!job) { break; } if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) { queuedIdentitiesRef.current.delete(job.identity); } if (!isCurrentJob(job)) { continue; } activeReadsRef.current.count += 1; pendingIdentitiesRef.current.set(job.identity, job.scopeEpoch); const jobPreviewKind = projectResourceCardPreviewKind(job.resource); publishPreview(job.identity, { status: 'loading' }); void readPreview(job) .then((transport) => { if (isCurrentJob(job)) { const materialized = materializeProjectResourceCardPreview(transport); if (!isCurrentJob(job)) { if (materialized.objectUrl) { URL.revokeObjectURL(materialized.objectUrl); } return; } publishPreview( job.identity, { status: 'loaded', preview: materialized.preview }, materialized, ); } }) .catch((error: unknown) => { if ( isCurrentJob(job) && !isProjectResourcePreviewCancellation(error) ) { const failure = previewReadErrorMessage( job.resource, jobPreviewKind, error, ); publishPreview(job.identity, { status: 'failed', ...failure, }); } }) .finally(() => { if ( pendingIdentitiesRef.current.get(job.identity) === job.scopeEpoch ) { pendingIdentitiesRef.current.delete(job.identity); } if (activeReadsRef.current.scopeEpoch === job.scopeEpoch) { activeReadsRef.current.count = Math.max( 0, activeReadsRef.current.count - 1, ); drainQueueRef.current(); } }); } }, [isCurrentJob, publishPreview, readPreview]); drainQueueRef.current = drainQueue; const touchCachedPreview = useCallback((identity: string) => { if (!cacheOrderRef.current.has(identity)) { return; } cacheOrderRef.current.delete(identity); cacheOrderRef.current.set(identity, true); }, []); const requestPreview = useCallback( ( resource: ProjectResource, identity: string, reason: PreviewRequestReason, ) => { if (identityByResourceIdRef.current.get(resource.id) !== identity) { return; } const kind = projectResourceCardPreviewKind(resource); if ( kind === 'version' || kind === 'placeholder' || (kind === 'audio' && reason !== 'play') ) { return; } const current = previewsRef.current.get(identity); if (current?.status === 'loaded') { touchCachedPreview(identity); return; } if (current?.status === 'failed') { if (reason === 'visible' || !current.retryable) { touchCachedPreview(identity); return; } } if ( current?.status === 'loading' || pendingIdentitiesRef.current.get(identity) === scopeEpochRef.current ) { return; } const queuedJob = queuedIdentitiesRef.current.get(identity) === scopeEpochRef.current ? queueRef.current.find((job) => job.identity === identity) : undefined; if (queuedJob) { if ( previewRequestPriority[reason] > previewRequestPriority[queuedJob.reason] ) { queuedJob.reason = reason; } return; } const visibleQueueLimit = PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT - PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE; if ( reason === 'visible' && queueRef.current.length >= visibleQueueLimit ) { return; } if ( queueRef.current.length >= PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT ) { let replaceIndex = -1; for (let index = queueRef.current.length - 1; index >= 0; index -= 1) { if (queueRef.current[index]?.reason === 'visible') { replaceIndex = index; break; } } if (reason === 'visible' || replaceIndex < 0) { return; } const [replacedJob] = queueRef.current.splice(replaceIndex, 1); if (replacedJob) { queuedIdentitiesRef.current.delete(replacedJob.identity); } } cacheOrderRef.current.delete(identity); queuedIdentitiesRef.current.set(identity, scopeEpochRef.current); queueRef.current.push({ scopeKey: scopeKeyRef.current, scopeId: scopeIdRef.current, scopeEpoch: scopeEpochRef.current, identity, resource, reason, }); drainQueueRef.current(); }, [touchCachedPreview], ); const observePreview = useCallback( (element: HTMLElement, resource: ProjectResource, identity: string) => { const binding = { resource, identity }; observedCardsRef.current.set(element, binding); observerRef.current?.observe(element); return () => { observerRef.current?.unobserve(element); if (observedCardsRef.current.get(element) === binding) { observedCardsRef.current.delete(element); } }; }, [], ); const failPreview = useCallback( (identity: string, error: string, retryable = false) => { if (!validIdentitiesRef.current.has(identity)) { return; } publishPreview(identity, { status: 'failed', error, retryable }); }, [publishPreview], ); const protectPreview = useCallback((identity: string | null) => { protectedIdentityRef.current = identity; }, []); useLayoutEffect(() => { if (scopeInitializedRef.current) { const previousScopeId = scopeIdRef.current; scopeIdRef.current = createProjectResourcePreviewScopeId(); cancelLocalProjectResourcePreviewScope(previousScopeId); } else { scopeInitializedRef.current = true; } scopeEpochRef.current += 1; activeReadsRef.current = { scopeEpoch: scopeEpochRef.current, count: 0, }; queueRef.current = []; queuedIdentitiesRef.current.clear(); pendingIdentitiesRef.current.clear(); disposeAllCachedPreviews(); cacheOrderRef.current.clear(); protectedIdentityRef.current = null; previewsRef.current = new Map(); setPreviews(new Map()); imageDimensionsByIdentityRef.current = new Map(); setImageDimensionsByIdentity(new Map()); }, [disposeAllCachedPreviews, scopeKey]); useEffect(() => { const validIdentities = new Set(identityByResourceId.values()); const current = previewsRef.current; const next = new Map( Array.from(current).filter(([identity]) => validIdentities.has(identity)), ); for (const identity of Array.from(cacheOrderRef.current.keys())) { if (!validIdentitiesRef.current.has(identity)) { cacheOrderRef.current.delete(identity); disposeCachedPreview(identity); } } if (next.size !== current.size) { previewsRef.current = next; setPreviews(next); } const currentImageDimensions = imageDimensionsByIdentityRef.current; const nextImageDimensions = new Map( Array.from(currentImageDimensions).filter(([identity]) => validIdentities.has(identity), ), ); if (nextImageDimensions.size !== currentImageDimensions.size) { imageDimensionsByIdentityRef.current = nextImageDimensions; setImageDimensionsByIdentity(nextImageDimensions); } }, [disposeCachedPreview, identityByResourceId]); useEffect(() => { const eagerPreviewLimit = Math.max( 0, Math.min( PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, Math.floor(input.eagerPreviewLimit ?? 0), ), ); let requested = 0; for (const resource of input.resources) { if (requested >= eagerPreviewLimit) { break; } const identity = identityByResourceId.get(resource.id); const kind = projectResourceCardPreviewKind(resource); if ( identity && kind !== 'version' && kind !== 'placeholder' && kind !== 'audio' ) { requestPreview(resource, identity, 'visible'); requested += 1; } } }, [ identityByResourceId, input.eagerPreviewLimit, input.resources, requestPreview, ]); const imageDimensionsByResourceId = useMemo(() => { const result = new Map< string, NonNullable> >(); for (const resource of input.resources) { const identity = identityByResourceId.get(resource.id); const kind = projectResourceCardPreviewKind(resource); if (kind !== 'raster-image' && kind !== 'media-image') { continue; } const dimensions = identity ? imageDimensionsByIdentity.get(identity) : undefined; if (dimensions) { result.set(resource.id, dimensions); } } return result; }, [identityByResourceId, imageDimensionsByIdentity, input.resources]); useEffect( () => () => { cancelLocalProjectResourcePreviewScope(scopeIdRef.current); scopeEpochRef.current += 1; queueRef.current = []; queuedIdentitiesRef.current.clear(); pendingIdentitiesRef.current.clear(); activeReadsRef.current = { scopeEpoch: scopeEpochRef.current, count: 0, }; disposeAllCachedPreviews(); }, [disposeAllCachedPreviews], ); useEffect(() => { const IntersectionObserverClass = window.IntersectionObserver; if (!IntersectionObserverClass) { return undefined; } const observer = new IntersectionObserverClass( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) { continue; } const binding = observedCardsRef.current.get( entry.target as HTMLElement, ); if (binding) { requestPreview(binding.resource, binding.identity, 'visible'); } } }, { root: input.intersectionRootRef?.current ?? input.canvasRef.current, rootMargin: '160px', }, ); observerRef.current = observer; for (const element of observedCardsRef.current.keys()) { observer.observe(element); } return () => { observer.disconnect(); if (observerRef.current === observer) { observerRef.current = null; } }; }, [input.canvasRef, input.intersectionRootRef, requestPreview, scopeKey]); return { identityByResourceId, previews, imageDimensionsByResourceId, idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW, observePreview, requestPreview, failPreview, protectPreview, }; }