Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
T
suzmii dde860716e 修复资源卡片媒体预览发不出请求:线上 category 改回原生读取分支
- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦
- read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝
- 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标
- previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category
- 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径
- 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值
- 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
2026-09-10 20:59:22 +08:00

781 lines
24 KiB
TypeScript

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<PreviewRequestReason, number> = {
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<HTMLDivElement | null>;
// 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器;
// 缺省回退到 canvasRef。
intersectionRootRef?: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
}) {
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<string, ProjectResourceCardPreviewState>
>(new Map());
const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState<
Map<
string,
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>
>(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<IntersectionObserver | null>(null);
const observedCardsRef = useRef(new Map<HTMLElement, ObservedPreviewCard>());
const queueRef = useRef<PreviewJob[]>([]);
const queuedIdentitiesRef = useRef(new Map<string, number>());
const pendingIdentitiesRef = useRef(new Map<string, number>());
const cacheOrderRef = useRef(new Map<string, true>());
const cachedPreviewsRef = useRef(new Map<string, CachedPreview>());
const cacheBytesRef = useRef(0);
const activeReadsRef = useRef({ scopeEpoch: 0, count: 0 });
const protectedIdentityRef = useRef<string | null>(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<ProjectResourceCardPreviewTransportPayload> => {
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<ProjectResourceCardPreviewTransportPayload>(
'read_local_project_image_preview',
{
projectPath: input.projectPath,
relativePath: job.resource.path,
scopeId: job.scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
}
if (kind === 'document' || kind === 'code') {
return invoke<ProjectResourceCardPreviewTransportPayload>(
'read_local_project_text_preview',
{
projectPath: input.projectPath,
relativePath: job.resource.path,
scopeId: job.scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
}
return invoke<ProjectResourceCardPreviewTransportPayload>(
'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<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>();
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,
};
}