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 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, hydrateLayer, isInlineEditorMediaSource, serializeCanvasLayout, 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'; 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 CachedEditorProjectSnapshot = { project: EditorProjectSnapshot; cachedAt: number; ownerUserId: string; revision: number; }; type ApplyProjectSnapshotOptions = { authoritative?: boolean; allowProjectSwitch?: boolean; }; type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; canvasGenerationDialogsRef: RefObject; canvasBackgroundColorRef: RefObject; }; type ImageCanvasProjectPersistenceSetters = { setProjectTitle: (title: string) => void; setProjectRenameValue: (title: string) => void; setViewport: (viewport: CanvasViewport) => void; setLayers: (layers: CanvasLayer[]) => 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, durationSeconds: layer.durationSeconds, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, 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 resources = layers.map((layer) => createEditorProjectSessionCacheResource({ projectId, layer }), ); if (resources.some((resource) => !resource)) { return null; } return { projectId, title: title.trim() || '未命名画布', viewport, layers: layoutItems, resources: resources.filter( (resource): resource is EditorProjectResourceSnapshot => Boolean(resource), ), 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 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 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 { setProjectTitle, setProjectRenameValue, setViewport, setLayers, 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 saveStillBelongsToCurrentAuthority = () => projectAuthorityEpochRef.current === saveAuthorityEpoch && currentUserIdRef.current === saveOwnerUserId && hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; const savePromise = saveEditorProjectLayout(pendingSave.projectId, { ...pendingSave.input, expectedRevision, }) .then((result) => { if ( saveStillBelongsToCurrentAuthority() && result && typeof result.revision === 'number' ) { projectRevisionRef.current = Math.max( projectRevisionRef.current ?? 0, result.revision, ); } 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; } applyProjectSnapshotRef.current?.(latestProject); }; 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 (pendingProjectLayoutSaveRef.current) { runNextSave = true; return; } if (!isRetryableEditorProjectLayoutSaveError(error)) { 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 (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; } 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; } if (shouldPersistCover) { void persistProjectCoverSnapshot( nextProjectId, input.viewport, refs.layersRef.current, ); } runPendingProjectLayoutSave(); }, [ currentUserId, persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave, ], ); const flushProjectPersistence = useCallback(async () => { 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, ); queueProjectLayoutSave( nextProjectId, { viewport: coverDisplayViewport, layers: serializeCanvasLayout({ layers: refs.layersRef.current, canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), }, { persistCover: false }, ); const coverSave = persistProjectCoverSnapshot( nextProjectId, coverDisplayViewport, refs.layersRef.current, ); 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; } } await coverSave; }, [ persistProjectCoverSnapshot, 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 } : (options.restoreMissingLayer || acceptedAuthoritativeSnapshotSequenceRef.current !== pendingLayer.authoritativeSnapshotSequence) && snapshotLayer ? { ...snapshotLayer, resourceId: pendingLayer.resourceId } : 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, durationSeconds: layer.durationSeconds, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, 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; } 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 nextViewport = canvasDisplayViewportToViewport(project.viewport); 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, durationSeconds: resource.durationSeconds, sourceResourceId: resource.sourceResourceId, assetKind: resource.assetKind, generationInputs: resource.generationInputs, createdAt: resource.createdAt, updatedAt: resource.updatedAt, }, ]), ); const { layerItems, generationDialogs, canvasBackgroundColor } = splitCanvasLayoutItems(project.layers, resourcesById, currentUserId); const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); setLayerCounter(hydratedLayers.length); refs.layersRef.current = hydratedLayers; setLayers(hydratedLayers); selectSingleLayer(hydratedLayers[0]?.id ?? null); refs.canvasGenerationDialogsRef.current = generationDialogs; restoreCanvasGenerationDialogs(generationDialogs); applyCanvasBackgroundColor( canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR, ); const projectIsAuthoritative = authoritative && hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === project.projectId; if (projectIsAuthoritative) { writeEditorProjectSessionCache( project, currentUserId, project.canvas?.revision, ); } return projectIsAuthoritative; }, [ applyCanvasBackgroundColor, clearPendingProjectLayoutSave, currentUserId, refs, restoreCanvasGenerationDialogs, selectSingleLayer, setLayerCounter, setLayers, 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; pendingCreatedProjectResourceLayersRef.current = []; clearPendingProjectLayoutSave(); setIsProjectReady(false); return undefined; } hasAuthoritativeProjectSnapshotRef.current = false; authoritativeProjectIdRef.current = null; projectRevisionRef.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, ); if (cachedProject) { applyProjectSnapshot(cachedProject.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject .then((project) => { if (cancelled) { return; } 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 () => { 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, projectIdRef, createProjectResourceForLayer, appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, }; }