import { type RefObject, useCallback, useEffect, useRef, useState, } from 'react'; import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes'; import { ApiClientError } from '../../services/apiClient'; import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient'; import { createEditorProjectResource, type EditorProjectLayerSnapshot, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, loadEditorProject, loadOrCreateRecentEditorProject, saveEditorProjectLayout, } from '../../services/image-editor/editorProjectClient'; import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache'; import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, dropDeadInlineGenerationPlaceholders, hydrateLayer, isInlineEditorMediaSource, isUnresolvedCanvasGenerationDialogRecord, resolveLayerResourceAssetKind, serializeCanvasLayout, serializeLayer, splitCanvasLayoutItems, viewportToCanvasDisplayViewport, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, normalizeProjectCoverSnapshotViewport, PROJECT_COVER_SNAPSHOT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_SIZE, type ProjectCoverSnapshotViewportSize, } from './ImageCanvasProjectCoverSnapshotModel'; import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer'; import { firstSelectedLayerId, normalizeCanvasSelectionIds, } from './ImageCanvasSelectionModel'; import { readPerfectPixelOperations, savePerfectPixelOperation, } from './perfectPixelOperationStore'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; snapshotLayers?: CanvasLayer[]; restoreMissingLayer?: boolean; }; type PendingProjectResourceLayer = { layer: CanvasLayer; options: ProjectResourceOptions; ownerUserId: string | null | undefined; targetProjectId: string | null; }; type PendingCreatedProjectResourceLayer = { layer: CanvasLayer; options: ProjectResourceOptions; projectId: string; ownerUserId: string | null | undefined; authoritativeSnapshotSequence: number; resourceId: string; }; type PendingProjectLayoutSave = { projectId: string; input: Omit< Parameters[1], 'expectedRevision' >; attemptExpectedRevision?: number; transportRetries?: number; }; type ActiveProjectLayoutSaveAttempt = { save: PendingProjectLayoutSave; authorityEpoch: number; ownerUserId: string | null | undefined; }; type CachedEditorProjectSnapshot = { project: EditorProjectSnapshot; cachedAt: number; ownerUserId: string; revision: number; }; type ApplyProjectSnapshotOptions = { authoritative?: boolean; allowProjectSwitch?: boolean; }; function canvasLayoutItemId(item: EditorProjectLayerSnapshot) { return typeof item.layerId === 'string' ? item.layerId : null; } function mergePendingCanvasLayerLayout( authoritative: EditorProjectLayerSnapshot, pending: EditorProjectLayerSnapshot, ): EditorProjectLayerSnapshot { if (pending.itemType === 'canvas-settings') { return pending; } if ( authoritative.itemType === 'generation-dialog' && pending.itemType === 'generation-dialog' ) { const authoritativeDialog = ( authoritative as { dialog?: CanvasGenerationDialogState } ).dialog; const pendingDialog = (pending as { dialog?: CanvasGenerationDialogState }) .dialog; if (authoritativeDialog && pendingDialog) { return { ...authoritative, dialog: { ...authoritativeDialog, ...pendingDialog, status: authoritativeDialog.status, generatedLayerId: authoritativeDialog.generatedLayerId, errorMessage: authoritativeDialog.errorMessage, generationStartedAt: authoritativeDialog.generationStartedAt, generationFinishedAt: authoritativeDialog.generationFinishedAt, characterAnimationResult: authoritativeDialog.characterAnimationResult, composerOpen: authoritativeDialog.composerOpen, }, }; } return pending; } return { ...authoritative, // pendingItems 来自 serializeCanvasLayout,这些布局字段始终存在;直接 // 覆盖才能保留 false / undefined 代表的解锁、取消分组和取消翻转。 title: pending.title, x: pending.x, y: pending.y, width: pending.width, height: pending.height, originalWidth: pending.originalWidth, originalHeight: pending.originalHeight, zIndex: pending.zIndex, groupId: pending.groupId, hidden: pending.hidden, locked: pending.locked, flipX: pending.flipX, flipY: pending.flipY, assetKindOverride: pending.assetKindOverride, }; } export function mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({ authoritativeItems, pendingItems, previousAuthoritativeItemIds, }: { authoritativeItems: EditorProjectLayerSnapshot[]; pendingItems: EditorProjectLayerSnapshot[]; previousAuthoritativeItemIds: ReadonlySet; }) { const authoritativeById = new Map( authoritativeItems.flatMap((item) => { const id = canvasLayoutItemId(item); return id ? [[id, item] as const] : []; }), ); const pendingIds = new Set(); const merged = pendingItems.flatMap((pendingItem) => { const id = canvasLayoutItemId(pendingItem); if (!id) { return [pendingItem]; } pendingIds.add(id); const authoritativeItem = authoritativeById.get(id); if (authoritativeItem) { return [mergePendingCanvasLayerLayout(authoritativeItem, pendingItem)]; } return previousAuthoritativeItemIds.has(id) ? [] : [pendingItem]; }); for (const authoritativeItem of authoritativeItems) { const id = canvasLayoutItemId(authoritativeItem); if (!id || pendingIds.has(id) || previousAuthoritativeItemIds.has(id)) { continue; } merged.push(authoritativeItem); } return merged; } type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; canvasGenerationDialogsRef: RefObject; getCanvasGenerationDialogsSnapshot?: () => CanvasGenerationDialogState[]; canvasBackgroundColorRef: RefObject; selectedLayerIdRef: RefObject; selectedLayerIdsRef: RefObject; }; type ImageCanvasProjectPersistenceSetters = { setProjectTitle: (title: string) => void; setProjectRenameValue: (title: string) => void; setViewport: (viewport: CanvasViewport) => void; setLayers: (layers: CanvasLayer[]) => void; setSelectedLayerId: (layerId: string | null) => void; setSelectedLayerIds: (layerIds: string[]) => void; selectSingleLayer: (layerId: string | null) => void; setLayerCounter: (value: number) => void; restoreCanvasGenerationDialogs: ( dialogs: CanvasGenerationDialogState[], ) => void; applyCanvasBackgroundColor: (color: string) => boolean; }; type ImageCanvasProjectPersistenceOptions = { refs: ImageCanvasProjectPersistenceRefs; setters: ImageCanvasProjectPersistenceSetters; layers: CanvasLayer[]; canvasGenerationDialogs: CanvasGenerationDialogState[]; viewport: CanvasViewport; canvasSize: ProjectCoverSnapshotViewportSize; canvasBackgroundColor: string; isViewportInteracting: boolean; canAccessProtectedData: boolean; currentUserId?: string | null; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; onProjectAccessLost?: () => void; }; function isEditorAuthError(error: unknown) { return error instanceof ApiClientError && error.status === 401; } function isEditorProjectAccessError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 403 || error.status === 404) ); } function isEditorProjectRevisionConflict(error: unknown) { return error instanceof ApiClientError && error.status === 409; } const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([ 408, 425, 429, 502, 503, 504, ]); function isRetryableEditorProjectLayoutSaveError(error: unknown) { return ( !(error instanceof ApiClientError) || EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES.has(error.status) ); } function isLocalProjectResourceId(resourceId: string) { return resourceId.startsWith('local-'); } function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) { const objectKey = layer.objectKey?.trim(); if (objectKey) { return `/${objectKey.replace(/^\/+/u, '')}`; } if (isInlineEditorMediaSource(layer.src)) { return null; } return layer.src; } const EDITOR_PROJECT_SESSION_CACHE_PREFIX = 'genarrative.imageCanvas.projectSnapshot.v2:'; function getEditorProjectSessionStorage() { try { return globalThis.sessionStorage ?? null; } catch { return null; } } function editorProjectSessionCacheKey(ownerUserId: string, projectId: string) { return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( ownerUserId, )}:${encodeURIComponent(projectId)}`; } function editorProjectRecentSessionCacheKey(ownerUserId: string) { return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( ownerUserId, )}:recent`; } function projectSnapshotContainsInlineMedia(project: EditorProjectSnapshot) { try { return /"(?:data:image\/|data:video\/|data:audio\/|blob:)/iu.test( JSON.stringify(project), ); } catch { return true; } } function readEditorProjectSessionCache( projectId: string | null, ownerUserId: string | null | undefined, ): CachedEditorProjectSnapshot | null { const storage = getEditorProjectSessionStorage(); const normalizedOwnerUserId = ownerUserId?.trim(); if (!storage || !normalizedOwnerUserId) { return null; } const key = projectId ? editorProjectSessionCacheKey(normalizedOwnerUserId, projectId) : editorProjectRecentSessionCacheKey(normalizedOwnerUserId); try { const rawValue = storage.getItem(key); if (!rawValue) { return null; } const cached = JSON.parse(rawValue) as Partial; if ( !cached.project || typeof cached.cachedAt !== 'number' || cached.ownerUserId !== normalizedOwnerUserId || typeof cached.revision !== 'number' ) { storage.removeItem(key); return null; } if ( projectId && cached.project.projectId && cached.project.projectId !== projectId ) { storage.removeItem(key); return null; } if (projectSnapshotContainsInlineMedia(cached.project)) { storage.removeItem(key); return null; } return { project: cached.project, cachedAt: cached.cachedAt, ownerUserId: cached.ownerUserId, revision: cached.revision, }; } catch { try { storage.removeItem(key); } catch { // ignore storage cleanup errors } return null; } } function removeEditorProjectSessionCache( projectId: string, ownerUserId: string | null | undefined, ) { const storage = getEditorProjectSessionStorage(); const normalizedOwnerUserId = ownerUserId?.trim(); if (!storage || !normalizedOwnerUserId) { return; } try { storage.removeItem( editorProjectSessionCacheKey(normalizedOwnerUserId, projectId), ); } catch { // ignore storage cleanup errors } } function writeEditorProjectSessionCache( project: EditorProjectSnapshot, ownerUserId: string | null | undefined, revision: number | null | undefined = project.canvas?.revision, ) { const normalizedOwnerUserId = ownerUserId?.trim(); if ( !normalizedOwnerUserId || typeof revision !== 'number' || projectSnapshotContainsInlineMedia(project) ) { return; } const storage = getEditorProjectSessionStorage(); if (!storage) { return; } const cached: CachedEditorProjectSnapshot = { project, cachedAt: Date.now(), ownerUserId: normalizedOwnerUserId, revision, }; try { const serialized = JSON.stringify(cached); storage.setItem( editorProjectSessionCacheKey(normalizedOwnerUserId, project.projectId), serialized, ); storage.setItem( editorProjectRecentSessionCacheKey(normalizedOwnerUserId), serialized, ); } catch { // ignore quota or privacy mode failures } } function createEditorProjectSessionCacheResource({ projectId, layer, }: { projectId: string; layer: CanvasLayer; }): EditorProjectResourceSnapshot | null { const imageSrc = resolveProjectResourceCreateImageSrc(layer); if (!imageSrc) { return null; } return { resourceId: layer.resourceId, projectId, imageSrc, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, width: layer.originalWidth, height: layer.originalHeight, sourceType: layer.sourceType, prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, provider: layer.provider, taskId: layer.taskId, imageSequenceFrames: layer.imageSequenceFrames, imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: resolveLayerResourceAssetKind(layer), generationInputs: layer.generationInputs, }; } function createEditorProjectSessionCacheSnapshot({ projectId, title, viewport, layoutItems, layers, }: { projectId: string; title: string; viewport: EditorProjectSnapshot['viewport']; layoutItems: EditorProjectSnapshot['layers']; layers: CanvasLayer[]; }): EditorProjectSnapshot | null { const resourcesById = new Map(); for (const layer of layers) { const resource = createEditorProjectSessionCacheResource({ projectId, layer, }); if (!resource) { return null; } if (!resourcesById.has(resource.resourceId)) { resourcesById.set(resource.resourceId, resource); } } return { projectId, title: title.trim() || '未命名画布', viewport, layers: layoutItems, resources: Array.from(resourcesById.values()), updatedAt: new Date().toISOString(), }; } export function useImageCanvasProjectPersistence({ refs, setters, layers, canvasGenerationDialogs, viewport, canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, currentUserId, openEditorLoginModal, onProjectAccessLost, }: ImageCanvasProjectPersistenceOptions) { const projectIdRef = useRef(null); const projectRevisionRef = useRef(null); const currentUserIdRef = useRef(currentUserId); currentUserIdRef.current = currentUserId; const canAccessProtectedDataRef = useRef(canAccessProtectedData); canAccessProtectedDataRef.current = canAccessProtectedData; const authoritativeProjectIdRef = useRef(null); const hasAuthoritativeProjectSnapshotRef = useRef(false); const acceptedAuthoritativeSnapshotSequenceRef = useRef(0); const authoritativeLayoutItemIdsRef = useRef>(new Set()); const lastAuthoritativeOwnerUserIdRef = useRef( undefined, ); const lastAuthoritativeProjectIdRef = useRef(null); const lastAuthoritativeRevisionRef = useRef(null); const projectAuthorityEpochRef = useRef(0); const applyProjectSnapshotRef = useRef< | (( project: EditorProjectSnapshot, options?: ApplyProjectSnapshotOptions, ) => boolean) | null >(null); const pendingProjectResourceLayersRef = useRef( [], ); const pendingCreatedProjectResourceLayersRef = useRef< PendingCreatedProjectResourceLayer[] >([]); const pendingProjectLayoutSaveRef = useRef( null, ); const isProjectLayoutSaveRunningRef = useRef(false); const activeProjectLayoutSavePromiseRef = useRef | null>(null); const activeProjectLayoutSaveAttemptRef = useRef(null); const skipNextProjectLayoutSaveRef = useRef(false); const saveTimerRef = useRef(null); const projectTitleRef = useRef('未命名画布'); const coverSnapshotSignatureRef = useRef(null); const coverSnapshotUploadRequestRef = useRef(0); const activeCoverSnapshotPersistenceRef = useRef<{ signature: string; promise: Promise; } | null>(null); const coverSnapshotViewportSizeRef = useRef(canvasSize); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const [assetKindFallbackNotice, setAssetKindFallbackNotice] = useState<{ id: number; message: string; } | null>(null); const assetKindFallbackNoticeIdRef = useRef(0); // 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位, // 布尔只会提示一次。调用方以计数变化为触发条件。 const [deadInlinePlaceholderDropCount, setDeadInlinePlaceholderDropCount] = useState(0); const { setProjectTitle, setProjectRenameValue, setViewport, setLayers, setSelectedLayerId, setSelectedLayerIds, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs, applyCanvasBackgroundColor, } = setters; coverSnapshotViewportSizeRef.current = canvasSize; const clearPendingProjectLayoutSave = useCallback(() => { pendingProjectLayoutSaveRef.current = null; if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }, []); const runPendingProjectLayoutSave = useCallback( function runPendingProjectLayoutSave() { if (isProjectLayoutSaveRunningRef.current) { return; } const pendingSave = pendingProjectLayoutSaveRef.current; if (!pendingSave) { return; } const expectedRevision = pendingSave.attemptExpectedRevision ?? projectRevisionRef.current; if ( !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== pendingSave.projectId || expectedRevision === null ) { pendingProjectLayoutSaveRef.current = null; return; } pendingProjectLayoutSaveRef.current = null; isProjectLayoutSaveRunningRef.current = true; const saveAuthorityEpoch = projectAuthorityEpochRef.current; const saveOwnerUserId = currentUserIdRef.current; const attemptedSave: PendingProjectLayoutSave = { ...pendingSave, attemptExpectedRevision: expectedRevision, }; const activeAttempt: ActiveProjectLayoutSaveAttempt = { save: attemptedSave, authorityEpoch: saveAuthorityEpoch, ownerUserId: saveOwnerUserId, }; activeProjectLayoutSaveAttemptRef.current = activeAttempt; const saveStillBelongsToCurrentAuthority = () => projectAuthorityEpochRef.current === saveAuthorityEpoch && currentUserIdRef.current === saveOwnerUserId && hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; const saveInput = { ...pendingSave.input, expectedRevision, }; const savePromise = saveEditorProjectLayout( pendingSave.projectId, saveInput, ) .then((result) => { const acknowledgedRevision = result && typeof result.revision === 'number' ? result.revision : null; if ( saveStillBelongsToCurrentAuthority() && acknowledgedRevision !== null && acknowledgedRevision > (projectRevisionRef.current ?? -1) ) { projectRevisionRef.current = acknowledgedRevision; authoritativeLayoutItemIdsRef.current = new Set( attemptedSave.input.layers.flatMap((item) => { const id = canvasLayoutItemId(item); return id ? [id] : []; }), ); } runNextSave = Boolean(pendingProjectLayoutSaveRef.current); }) .catch(async (error: unknown) => { if (!saveStillBelongsToCurrentAuthority()) { runNextSave = Boolean(pendingProjectLayoutSaveRef.current); return; } if (isEditorAuthError(error)) { openEditorLoginModal(); return; } if (isEditorProjectRevisionConflict(error)) { const applyLatestProject = ( latestProject: EditorProjectSnapshot, ) => { if (!saveStillBelongsToCurrentAuthority()) { return false; } const applied = applyProjectSnapshotRef.current?.(latestProject) === true; runNextSave = Boolean(pendingProjectLayoutSaveRef.current); return applied; }; const scheduleAuthoritativeReload = (retryCount: number) => { if ( !saveStillBelongsToCurrentAuthority() || retryCount >= 3 || saveTimerRef.current !== null ) { return; } saveTimerRef.current = window.setTimeout( () => { saveTimerRef.current = null; if (!saveStillBelongsToCurrentAuthority()) { return; } void loadEditorProject(pendingSave.projectId) .then(applyLatestProject) .catch((reloadError: unknown) => { if (!saveStillBelongsToCurrentAuthority()) { return; } if (isEditorAuthError(reloadError)) { openEditorLoginModal(); return; } scheduleAuthoritativeReload(retryCount + 1); }); }, Math.min(1_000 * 2 ** retryCount, 8_000), ); }; try { const latestProject = await loadEditorProject( pendingSave.projectId, ); applyLatestProject(latestProject); } catch (reloadError: unknown) { if (isEditorAuthError(reloadError)) { openEditorLoginModal(); return; } scheduleAuthoritativeReload(0); } return; } if (!isRetryableEditorProjectLayoutSaveError(error)) { if (pendingProjectLayoutSaveRef.current) { runNextSave = true; } return; } if (pendingProjectLayoutSaveRef.current) { runNextSave = true; return; } const transportRetries = pendingSave.transportRetries ?? 0; if (transportRetries < 3) { pendingProjectLayoutSaveRef.current = { ...attemptedSave, transportRetries: transportRetries + 1, }; if (saveTimerRef.current === null) { saveTimerRef.current = window.setTimeout( () => { saveTimerRef.current = null; runPendingProjectLayoutSave(); }, Math.min(1_000 * 2 ** transportRetries, 8_000), ); } } }) .finally(() => { if (activeProjectLayoutSaveAttemptRef.current === activeAttempt) { activeProjectLayoutSaveAttemptRef.current = null; } if (activeProjectLayoutSavePromiseRef.current === savePromise) { activeProjectLayoutSavePromiseRef.current = null; } isProjectLayoutSaveRunningRef.current = false; if ( runNextSave && pendingProjectLayoutSaveRef.current && saveTimerRef.current === null ) { runPendingProjectLayoutSave(); } }); activeProjectLayoutSavePromiseRef.current = savePromise; void savePromise; }, [openEditorLoginModal], ); const persistProjectCoverSnapshot = useCallback( ( nextProjectId: string, coverDisplayViewport: CanvasViewport, coverLayers: readonly CanvasLayer[], ) => { if (!canAccessProtectedData) { return Promise.resolve(); } const rawCoverViewport = canvasDisplayViewportToViewport(coverDisplayViewport); const { viewport: coverViewport, viewportSize } = normalizeProjectCoverSnapshotViewport({ viewport: rawCoverViewport, viewportSize: coverSnapshotViewportSizeRef.current, }); const backgroundColor = refs.canvasBackgroundColorRef.current; const signature = buildProjectCoverSnapshotSignature({ layers: coverLayers, viewport: coverViewport, viewportSize, backgroundColor, }); if (!signature) { return Promise.resolve(); } if (coverSnapshotSignatureRef.current === signature) { return activeCoverSnapshotPersistenceRef.current?.signature === signature ? activeCoverSnapshotPersistenceRef.current.promise : Promise.resolve(); } coverSnapshotSignatureRef.current = signature; coverSnapshotUploadRequestRef.current += 1; const requestId = coverSnapshotUploadRequestRef.current; const persistencePromise = createProjectCoverSnapshotBlob({ layers: coverLayers, viewport: coverViewport, viewportSize, backgroundColor, }) .then(async (blob) => { if (!blob) { if (coverSnapshotSignatureRef.current === signature) { coverSnapshotSignatureRef.current = null; } return; } if (coverSnapshotUploadRequestRef.current !== requestId) { return; } await putEditorProjectCoverCache({ projectId: nextProjectId, blob, }); const coverFileExtension = blob.type === 'image/webp' ? 'webp' : 'png'; const coverFile = new File( [blob], `${nextProjectId}-cover.${coverFileExtension}`, { type: blob.type || 'image/webp' }, ); const uploadedCover = await uploadEditorMediaAssetObjectFile( coverFile, 'image', { assetKind: PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, pathSegments: [ 'editor', 'project-covers', nextProjectId, `${Date.now()}`, ], entityId: `editor-project-cover-${nextProjectId}`, metadata: { editor_project_id: nextProjectId, }, }, ); if (coverSnapshotUploadRequestRef.current !== requestId) { return; } await createEditorProjectResource(nextProjectId, { imageSrc: uploadedCover.legacyPublicPath, objectKey: uploadedCover.objectKey, assetObjectId: uploadedCover.assetObjectId, width: PROJECT_COVER_SNAPSHOT_SIZE.width, height: PROJECT_COVER_SNAPSHOT_SIZE.height, sourceType: 'uploaded', assetKind: PROJECT_COVER_SNAPSHOT_ASSET_KIND, }); }) .catch((error: unknown) => { if (coverSnapshotSignatureRef.current === signature) { coverSnapshotSignatureRef.current = null; } if (isEditorAuthError(error)) { openEditorLoginModal(); } }) .finally(() => { if ( activeCoverSnapshotPersistenceRef.current?.promise === persistencePromise ) { activeCoverSnapshotPersistenceRef.current = null; } }); activeCoverSnapshotPersistenceRef.current = { signature, promise: persistencePromise, }; return persistencePromise; }, [canAccessProtectedData, openEditorLoginModal, refs], ); const queueProjectLayoutSave = useCallback( ( nextProjectId: string, input: Omit< Parameters[1], 'expectedRevision' >, options: { delayMs?: number; persistCover?: boolean; } = {}, ) => { const revision = projectRevisionRef.current; if ( !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== nextProjectId || revision === null ) { return false; } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, input, }; const sessionSnapshot = createEditorProjectSessionCacheSnapshot({ projectId: nextProjectId, title: projectTitleRef.current, viewport: input.viewport, layoutItems: input.layers, layers: refs.layersRef.current, }); if (sessionSnapshot) { writeEditorProjectSessionCache( sessionSnapshot, currentUserId, revision, ); } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } const delayMs = options.delayMs ?? 0; const shouldPersistCover = options.persistCover !== false; if (delayMs > 0) { saveTimerRef.current = window.setTimeout(() => { saveTimerRef.current = null; if (shouldPersistCover) { void persistProjectCoverSnapshot( nextProjectId, input.viewport, refs.layersRef.current, ); } runPendingProjectLayoutSave(); }, delayMs); return true; } if (shouldPersistCover) { void persistProjectCoverSnapshot( nextProjectId, input.viewport, refs.layersRef.current, ); } runPendingProjectLayoutSave(); return true; }, [ currentUserId, persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave, ], ); /** * 中文注释:布局保存只有「尽力而为」这一种语义。 * * 曾经存在一条 strict 变体:完美像素在发 POST 前必须拿到布局保存的 revision ack,因为 * 请求账本当时写在布局里。账本移到本机之后(见 perfectPixelOperationStore)那个前置 * 条件不再成立,strict 通道连同它引入的阻断一起删除——布局保存失败此后只是「这次画布 * 状态没同步上去」,不再能拦住任何生成操作。 * * `preferLatestGenerationDialogs` 只影响取哪一份占位快照:置位时取本轮 render 之前已 * 提交的最新占位,避免刚创建的占位因为 ref 落后一帧而漏存。它不改变失败语义。 */ const flushProjectPersistence = useCallback( async ( options: { preferLatestGenerationDialogs?: boolean; } = {}, ) => { while (activeProjectLayoutSavePromiseRef.current) { await activeProjectLayoutSavePromiseRef.current; } const nextProjectId = projectIdRef.current; if ( !nextProjectId || !canAccessProtectedDataRef.current || !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== nextProjectId || projectRevisionRef.current === null ) { return; } const coverDisplayViewport = viewportToCanvasDisplayViewport( refs.viewportRef.current, ); const generationDialogs = options.preferLatestGenerationDialogs && refs.getCanvasGenerationDialogsSnapshot ? refs.getCanvasGenerationDialogsSnapshot() : refs.canvasGenerationDialogsRef.current; const layoutInput = { viewport: coverDisplayViewport, layers: serializeCanvasLayout({ layers: refs.layersRef.current, canvasGenerationDialogs: generationDialogs, canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), }; // 中文注释:封面走 `queueProjectLayoutSave` 内建的 fire-and-forget 分支,flush **不等** // 它。此前 flush 显式关掉那条分支、自己起一份并在最后 `await`,于是任何 await flush 的 // 调用方都被挂在封面链后面——而封面渲染要为每个可绘制图层取 signed URL 再 `new Image()` // 加载,那个 Image 没有 timeout 也没有 AbortSignal,一张图不 settle 就永远不 settle。 // 完美像素与图集拆分 await flush 只为满足一个服务端前置:占位/图层布局已经持久化。那个 // 前置在下面的队列排干时就已满足,封面与它无关,不该挡在 POST 前面。 queueProjectLayoutSave(nextProjectId, layoutInput); while ( activeProjectLayoutSavePromiseRef.current || pendingProjectLayoutSaveRef.current ) { const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; if (activeLayoutSave) { await activeLayoutSave; continue; } if (saveTimerRef.current !== null) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } runPendingProjectLayoutSave(); if (!activeProjectLayoutSavePromiseRef.current) { break; } } }, [queueProjectLayoutSave, refs, runPendingProjectLayoutSave], ); const applyCreatedProjectResourceLayer = useCallback( (pendingLayer: PendingCreatedProjectResourceLayer) => { if ( currentUserIdRef.current !== pendingLayer.ownerUserId || projectIdRef.current !== pendingLayer.projectId || !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== pendingLayer.projectId ) { return false; } const { layer, options } = pendingLayer; options.onCreated?.(pendingLayer.resourceId); const currentLayers = refs.layersRef.current; const currentLayer = currentLayers.find( (candidate) => candidate.id === layer.id, ); const snapshotLayer = options.snapshotLayers?.find( (candidate) => candidate.id === layer.id, ); const layerToPersist = currentLayer ? { ...currentLayer, resourceId: pendingLayer.resourceId, resourcePersistenceState: 'registered' as const, } : (options.restoreMissingLayer || acceptedAuthoritativeSnapshotSequenceRef.current !== pendingLayer.authoritativeSnapshotSequence) && snapshotLayer ? { ...snapshotLayer, resourceId: pendingLayer.resourceId, resourcePersistenceState: 'registered' as const, } : null; if (!layerToPersist) { return true; } const nextLayers = currentLayer ? currentLayers.map((candidate) => candidate.id === layer.id ? layerToPersist : candidate, ) : [...currentLayers, layerToPersist]; refs.layersRef.current = nextLayers; setLayers(nextLayers); queueProjectLayoutSave(pendingLayer.projectId, { viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current), layers: serializeCanvasLayout({ layers: nextLayers, canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), }); return true; }, [queueProjectLayoutSave, refs, setLayers], ); const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { const readyProjectId = projectIdRef.current; if ( !readyProjectId || !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== readyProjectId ) { pendingProjectResourceLayersRef.current.push({ layer, options: { ...options, restoreMissingLayer: true }, ownerUserId: currentUserIdRef.current, targetProjectId: readyProjectId, }); return; } const requestOwnerUserId = currentUserIdRef.current; const requestAuthoritativeSnapshotSequence = acceptedAuthoritativeSnapshotSequenceRef.current; const imageSrc = resolveProjectResourceCreateImageSrc(layer); if (!imageSrc) { return; } createEditorProjectResource(readyProjectId, { imageSrc, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, width: layer.originalWidth, height: layer.originalHeight, sourceType: layer.sourceType, prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, provider: layer.provider, taskId: layer.taskId, imageSequenceFrames: layer.imageSequenceFrames, imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: resolveLayerResourceAssetKind(layer), generationInputs: layer.generationInputs, }) .then((resource) => { const pendingLayer: PendingCreatedProjectResourceLayer = { projectId: readyProjectId, ownerUserId: requestOwnerUserId, authoritativeSnapshotSequence: requestAuthoritativeSnapshotSequence, layer, options, resourceId: resource.resourceId, }; if ( currentUserIdRef.current !== requestOwnerUserId || projectIdRef.current !== readyProjectId ) { return; } if (!applyCreatedProjectResourceLayer(pendingLayer)) { pendingCreatedProjectResourceLayersRef.current.push(pendingLayer); } }) .catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } }); }, [applyCreatedProjectResourceLayer, openEditorLoginModal], ); const drainPendingProjectResourceLayers = useCallback( (readyProjectId: string) => { const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); pendingLayers.forEach((pendingLayer) => { if ( pendingLayer.ownerUserId === currentUserIdRef.current && (pendingLayer.targetProjectId === null || pendingLayer.targetProjectId === readyProjectId) ) { createProjectResourceForLayer( pendingLayer.layer, pendingLayer.options, ); } }); const pendingCreatedLayers = pendingCreatedProjectResourceLayersRef.current.splice(0); pendingCreatedLayers.forEach((pendingLayer) => { applyCreatedProjectResourceLayer(pendingLayer); }); }, [applyCreatedProjectResourceLayer, createProjectResourceForLayer], ); const applyProjectSnapshot = useCallback( ( project: EditorProjectSnapshot, { authoritative = true, allowProjectSwitch = false, }: ApplyProjectSnapshotOptions = {}, ) => { if (authoritative) { const activeProjectId = projectIdRef.current; const incomingRevision = project.canvas?.revision; const hasCurrentProjectAuthority = hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === project.projectId; const canResumeSameUserProjectAuthority = !hasCurrentProjectAuthority && canAccessProtectedDataRef.current && activeProjectId === project.projectId && lastAuthoritativeOwnerUserIdRef.current === currentUserId && lastAuthoritativeProjectIdRef.current === project.projectId && typeof incomingRevision === 'number'; const lastKnownRevision = lastAuthoritativeOwnerUserIdRef.current === currentUserId && lastAuthoritativeProjectIdRef.current === project.projectId ? lastAuthoritativeRevisionRef.current : null; const minimumAcceptedRevision = Math.max( projectRevisionRef.current ?? 0, lastKnownRevision ?? 0, ); if ( currentUserIdRef.current !== currentUserId || (!allowProjectSwitch && !hasCurrentProjectAuthority && !canResumeSameUserProjectAuthority) || (!allowProjectSwitch && activeProjectId !== null && activeProjectId !== project.projectId) || (activeProjectId === project.projectId && (typeof incomingRevision !== 'number' || incomingRevision < minimumAcceptedRevision)) ) { return false; } acceptedAuthoritativeSnapshotSequenceRef.current += 1; } const pendingSave = pendingProjectLayoutSaveRef.current; const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current; const activeLocalSave = authoritative && activeSaveAttempt?.save.projectId === project.projectId && activeSaveAttempt.authorityEpoch === projectAuthorityEpochRef.current && activeSaveAttempt.ownerUserId === currentUserIdRef.current ? activeSaveAttempt.save : null; const pendingLocalLayout = authoritative ? pendingSave?.projectId === project.projectId ? pendingSave : activeLocalSave : null; const previousAuthoritativeItemIds = authoritativeLayoutItemIdsRef.current; const applyingToCurrentProject = projectIdRef.current === project.projectId; clearPendingProjectLayoutSave(); skipNextProjectLayoutSaveRef.current = true; if (projectIdRef.current !== project.projectId) { coverSnapshotSignatureRef.current = null; coverSnapshotUploadRequestRef.current += 1; } projectIdRef.current = project.projectId; if (authoritative) { const revision = project.canvas?.revision; const hasRevision = typeof revision === 'number'; projectRevisionRef.current = hasRevision ? revision : null; authoritativeProjectIdRef.current = hasRevision ? project.projectId : null; hasAuthoritativeProjectSnapshotRef.current = hasRevision; if (hasRevision) { lastAuthoritativeOwnerUserIdRef.current = currentUserId; lastAuthoritativeProjectIdRef.current = project.projectId; lastAuthoritativeRevisionRef.current = revision; } } setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; projectTitleRef.current = nextProjectTitle; setProjectTitle(nextProjectTitle); setProjectRenameValue(nextProjectTitle); const appliedLayoutItems = pendingLocalLayout ? mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({ authoritativeItems: project.layers, pendingItems: pendingLocalLayout.input.layers, previousAuthoritativeItemIds, }) : project.layers; if (authoritative) { authoritativeLayoutItemIdsRef.current = new Set( project.layers.flatMap((item) => { const id = canvasLayoutItemId(item); return id ? [id] : []; }), ); } const appliedViewport = pendingLocalLayout ? pendingLocalLayout.input.viewport : project.viewport; const nextViewport = canvasDisplayViewportToViewport(appliedViewport); setViewport(nextViewport); refs.viewportRef.current = nextViewport; const resourcesById = new Map( project.resources.map((resource) => [ resource.resourceId, { imageSrc: resource.imageSrc, resourceId: resource.resourceId, ownerUserId: resource.ownerUserId, objectKey: resource.objectKey, assetObjectId: resource.assetObjectId, width: resource.width, height: resource.height, sourceType: resource.sourceType, prompt: resource.prompt, actualPrompt: resource.actualPrompt, model: resource.model, provider: resource.provider, taskId: resource.taskId, imageSequenceFrames: resource.imageSequenceFrames, imageSequenceDurationMs: resource.imageSequenceDurationMs, sourceResourceId: resource.sourceResourceId, assetKind: resource.assetKind, generationInputs: resource.generationInputs, createdAt: resource.createdAt, updatedAt: resource.updatedAt, }, ]), ); // 中文注释:请求账本只在本机。读不到(换设备、清缓存、隐私模式)时占位会被 // hydrate 收口成可删除的失败态——这是明确设计,不阻断任何后续操作。 const localPerfectPixelOperations = readPerfectPixelOperations( currentUserId, project.projectId, ); const { layerItems, generationDialogs, canvasBackgroundColor } = splitCanvasLayoutItems( appliedLayoutItems, resourcesById, currentUserId, localPerfectPixelOperations, ); const assetKindFallbackLayerIds = new Set(); // 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成 // `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移 // 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact // retry 的 identity。 // // 判据用「未收口」而不是列举状态:`failed + perfectPixelOperation` 是旧严格保存失败 // 的合法持久化形状,`retryPerfectPixelOperation` 也明确接受 `failed`,按状态白名单 // 列举会把这批仍能 exact retry 的占位漏掉。收口态(带 generatedLayerId)本就不需要 // 账本,与 `hydrateCanvasGenerationDialog` 同用一个判据,两处不会漂移。 // 只补写本机缺失的:本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新。 for (const dialog of generationDialogs) { const operation = dialog.perfectPixelOperation; if ( operation && !localPerfectPixelOperations.has(dialog.id) && isUnresolvedCanvasGenerationDialogRecord( dialog as unknown as Record, ) ) { savePerfectPixelOperation( currentUserId, project.projectId, operation, ); } } const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById, { onAssetKindOverrideFallback: (issue) => { assetKindFallbackLayerIds.add(issue.layerId); }, }), ) .filter((layer): layer is CanvasLayer => Boolean(layer)); const hydratedLayersById = new Map( hydratedLayers.map((layer) => [layer.id, layer]), ); const repairedLayoutItems = assetKindFallbackLayerIds.size ? appliedLayoutItems.map((item) => { const layerId = canvasLayoutItemId(item); const hydratedLayer = layerId ? hydratedLayersById.get(layerId) : undefined; return layerId && assetKindFallbackLayerIds.has(layerId) && hydratedLayer ? serializeLayer(hydratedLayer) : item; }) : appliedLayoutItems; setLayerCounter(hydratedLayers.length); refs.layersRef.current = hydratedLayers; setLayers(hydratedLayers); if (applyingToCurrentProject) { const currentSelectionIds = refs.selectedLayerIdsRef.current.length ? refs.selectedLayerIdsRef.current : refs.selectedLayerIdRef.current ? [refs.selectedLayerIdRef.current] : []; const nextSelectionIds = normalizeCanvasSelectionIds({ selectionIds: currentSelectionIds, layers: hydratedLayers, generationDialogs, }); const nextSelectedLayerId = firstSelectedLayerId(nextSelectionIds); refs.selectedLayerIdRef.current = nextSelectedLayerId; refs.selectedLayerIdsRef.current = nextSelectionIds; setSelectedLayerId(nextSelectedLayerId); setSelectedLayerIds(nextSelectionIds); } else { const nextSelectedLayerId = hydratedLayers[0]?.id ?? null; refs.selectedLayerIdRef.current = nextSelectedLayerId; refs.selectedLayerIdsRef.current = nextSelectedLayerId ? [nextSelectedLayerId] : []; selectSingleLayer(nextSelectedLayerId); } refs.canvasGenerationDialogsRef.current = generationDialogs; restoreCanvasGenerationDialogs(generationDialogs); applyCanvasBackgroundColor( canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR, ); const projectIsAuthoritative = authoritative && hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === project.projectId; if (projectIsAuthoritative) { if (assetKindFallbackLayerIds.size) { assetKindFallbackNoticeIdRef.current += 1; setAssetKindFallbackNotice({ id: assetKindFallbackNoticeIdRef.current, message: `检测到 ${assetKindFallbackLayerIds.size} 个图层的素材标签与媒体类型不兼容,已保留图层并恢复为素材默认标签。`, }); } writeEditorProjectSessionCache( pendingLocalLayout || assetKindFallbackLayerIds.size ? { ...project, viewport: appliedViewport, layers: repairedLayoutItems, } : project, currentUserId, project.canvas?.revision, ); if (pendingLocalLayout || assetKindFallbackLayerIds.size) { queueProjectLayoutSave(project.projectId, { viewport: appliedViewport, layers: repairedLayoutItems, }); } } return projectIsAuthoritative; }, [ applyCanvasBackgroundColor, clearPendingProjectLayoutSave, currentUserId, queueProjectLayoutSave, refs, restoreCanvasGenerationDialogs, selectSingleLayer, setLayerCounter, setLayers, setSelectedLayerId, setSelectedLayerIds, setProjectRenameValue, setProjectTitle, setViewport, ], ); applyProjectSnapshotRef.current = applyProjectSnapshot; const appendCanvasLayersWithResources = useCallback( (nextLayers: CanvasLayer[]) => { if (!nextLayers.length) { return; } const snapshotLayers = [...refs.layersRef.current, ...nextLayers]; refs.layersRef.current = snapshotLayers; setLayers(snapshotLayers); nextLayers.forEach((layer) => isLocalProjectResourceId(layer.resourceId) ? createProjectResourceForLayer(layer, { snapshotLayers }) : undefined, ); }, [createProjectResourceForLayer, refs, setLayers], ); useEffect(() => { projectAuthorityEpochRef.current += 1; coverSnapshotUploadRequestRef.current += 1; if (!canAccessProtectedData) { hasAuthoritativeProjectSnapshotRef.current = false; authoritativeProjectIdRef.current = null; projectRevisionRef.current = null; authoritativeLayoutItemIdsRef.current = new Set(); activeProjectLayoutSaveAttemptRef.current = null; pendingCreatedProjectResourceLayersRef.current = []; clearPendingProjectLayoutSave(); setIsProjectReady(false); return undefined; } hasAuthoritativeProjectSnapshotRef.current = false; authoritativeProjectIdRef.current = null; projectRevisionRef.current = null; authoritativeLayoutItemIdsRef.current = new Set(); activeProjectLayoutSaveAttemptRef.current = null; clearPendingProjectLayoutSave(); setIsProjectReady(false); let cancelled = false; const projectIdFromQuery = typeof window === 'undefined' ? null : new URLSearchParams(window.location.search) .get('projectid') ?.trim() || null; const cachedProject = readEditorProjectSessionCache( projectIdFromQuery, currentUserId, ); // 中文注释:缓存里剥掉的数量单独记,不能直接并入提示计数。会话缓存可能停留在完美像素 // 完成之前的版本(`applyProjectSnapshot` 置了 skipNext,成功后的第一次 effect 不落库), // 此时缓存里的占位是陈旧的而权威快照其实已经成功——并入就会报一条「上次处理未完成」的 // 假告警。只有权威加载没能纠正这幅画面时,这个计数才代表真的有孤儿占位被静默清掉。 let cachedDeadPlaceholderCount = 0; if (cachedProject) { // 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位, // 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。 const cached = dropDeadInlineGenerationPlaceholders( cachedProject.project, ); cachedDeadPlaceholderCount = cached.droppedCount; applyProjectSnapshot(cached.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject .then((loadedProject) => { if (cancelled) { return; } // 中文注释:只在这里剥离。会话内 applyQueuedEditorGenerationProject 也会重新 GET // 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。 const { project, droppedCount } = dropDeadInlineGenerationPlaceholders(loadedProject); if (droppedCount > 0) { setDeadInlinePlaceholderDropCount( (currentCount) => currentCount + droppedCount, ); } const projectIsAuthoritative = applyProjectSnapshot(project, { allowProjectSwitch: true, }); if (!projectIsAuthoritative) { const activeAuthoritativeProjectId = authoritativeProjectIdRef.current; if ( hasAuthoritativeProjectSnapshotRef.current && activeAuthoritativeProjectId !== null && activeAuthoritativeProjectId === projectIdRef.current ) { drainPendingProjectResourceLayers(activeAuthoritativeProjectId); setIsProjectReady(true); } else { setIsProjectReady(false); } return; } drainPendingProjectResourceLayers(project.projectId); setIsProjectReady(true); }) .catch((error: unknown) => { if (cancelled) { return; } setIsProjectReady(false); if (isEditorAuthError(error)) { openEditorLoginModal(() => { window.location.reload(); }); return; } if (projectIdFromQuery && isEditorProjectAccessError(error)) { removeEditorProjectSessionCache(projectIdFromQuery, currentUserId); if (onProjectAccessLost) { onProjectAccessLost(); return; } replaceAppHistoryPath('/project'); return; } // 中文注释:权威快照没能到达,缓存里剥掉的占位就没有第二个来源可以纠正。此时画布 // 仍在渲染(isProjectReady 只管启动意图与自动保存,不挡画面),用户看到的是一张 // 静默少了占位的画布——必须提示,否则他既不知道占位被清掉,也不知道素材库可能已有 // 派生图。鉴权失败与项目失访这两条路径已各自跳转或弹窗,不在这里重复打扰。 if (cachedDeadPlaceholderCount > 0) { setDeadInlinePlaceholderDropCount( (currentCount) => currentCount + cachedDeadPlaceholderCount, ); } }); return () => { cancelled = true; }; }, [ canAccessProtectedData, applyProjectSnapshot, clearPendingProjectLayoutSave, createProjectResourceForLayer, currentUserId, drainPendingProjectResourceLayers, onProjectAccessLost, openEditorLoginModal, ]); useEffect(() => { return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; }, []); useEffect(() => { if ( !projectId || !isProjectReady || !hasAuthoritativeProjectSnapshotRef.current || authoritativeProjectIdRef.current !== projectId || projectRevisionRef.current === null ) { return undefined; } if (skipNextProjectLayoutSaveRef.current) { skipNextProjectLayoutSaveRef.current = false; if (!isViewportInteracting) { persistProjectCoverSnapshot( projectId, viewportToCanvasDisplayViewport(viewport), layers, ); } return undefined; } if (isViewportInteracting) { return undefined; } queueProjectLayoutSave( projectId, { viewport: viewportToCanvasDisplayViewport(viewport), layers: serializeCanvasLayout({ layers, canvasGenerationDialogs, canvasBackgroundColor, }), }, { delayMs: 450 }, ); return undefined; }, [ isProjectReady, canvasGenerationDialogs, canvasBackgroundColor, isViewportInteracting, layers, persistProjectCoverSnapshot, projectId, queueProjectLayoutSave, refs, viewport, ]); return { projectId, isProjectReady, assetKindFallbackNotice, projectIdRef, createProjectResourceForLayer, appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, deadInlinePlaceholderDropCount, }; }