532c6d51fd
## 背景 本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。 当前远端比较基线: - base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d` - head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472` - 相对 base:28 commits、33 files(`+3746 / -1333`) ## 主要改动 ### 资源管理分页自由画布 - 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。 - `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。 - 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。 - 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。 - 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。 - 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。 ### 图片持续精修与生成事务 - 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。 - 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。 - 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。 - 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。 - 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。 ### 合同与文档 - `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。 - 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。 - 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。 ## 验证 CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功: - [x] Repository checks - [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests) - [x] Backend tests - [x] Native shell tests 另有本地定向检查: - [x] `npm run agc:typecheck` - [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归 - [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试 - [x] `cargo fmt --check` - [x] `npm run check:encoding` - [x] `git diff --check` ## 评审 provenance - review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。 - review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。 - 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。 - 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。 ## 影响范围 - 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。 - 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。 - 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
768 lines
24 KiB
TypeScript
768 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,
|
|
projectResourceCardPreviewKind,
|
|
type ProjectResourceCardPreviewPayload,
|
|
type ProjectResourceCardPreviewState,
|
|
type ProjectResourceCardPreviewTransportPayload,
|
|
} 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 previewReadErrorMessage(
|
|
resource: ProjectResource,
|
|
error: unknown,
|
|
): { error: string; retryable: boolean } {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (message.includes('项目权限策略要求用户确认')) {
|
|
return {
|
|
error: `当前项目策略要求先确认读取${
|
|
resource.category === 'document'
|
|
? '文档'
|
|
: resource.category === 'code'
|
|
? '游戏代码'
|
|
: '资源'
|
|
},确认后请关闭详情并重试`,
|
|
retryable: true,
|
|
};
|
|
}
|
|
if (message.includes('项目权限策略拒绝执行')) {
|
|
return {
|
|
error: `当前项目策略不允许读取${
|
|
resource.category === 'document'
|
|
? '文档'
|
|
: resource.category === 'code'
|
|
? '游戏代码'
|
|
: '资源'
|
|
},调整策略后请关闭详情并重试`,
|
|
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>;
|
|
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: job.resource.category,
|
|
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);
|
|
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, 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.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, requestPreview, scopeKey]);
|
|
|
|
return {
|
|
identityByResourceId,
|
|
previews,
|
|
imageDimensionsByResourceId,
|
|
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
|
|
observePreview,
|
|
requestPreview,
|
|
failPreview,
|
|
protectPreview,
|
|
};
|
|
}
|