Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
T
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 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>
2026-08-24 20:42:58 +08:00

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,
};
}