Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.ts
T
lhk229 dee91b6889
Project CI / Repository checks (pull_request) Successful in 1m11s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / Backend tests (pull_request) Successful in 3m52s
Project CI / Native shell tests (pull_request) Successful in 12m6s
布局 flush 不再等待封面链
flushProjectPersistence 显式关掉 queueProjectLayoutSave 内建的 fire-and-forget
封面分支,自己另起一份并在最后 await。于是每个 await flush 的调用方都被挂在
封面链后面:封面渲染要为每个可绘制图层取 signed URL 再 new Image() 加载,而
那个 Image 只有 onload/onerror,没有 timeout 也没有 AbortSignal,外层的
try/catch 只接得住 reject、接不住「永不 settle」。一张图不 settle,生成 POST
就永远发不出去。

这是「完美像素请求账本移出项目布局」把严格通道与普通 flush 合并成一条路径时
引入的回归——改动前 strict 分支在封面链启动之前就 return,两者是结构性隔离的。
图集拆分一直走非严格路径,暴露是既有的,但同源,一并解开。

必然发生的是延迟:封面签名含未量化的 viewport,而创建占位时会移动视口,所以
几乎每次完美像素都触发全量重渲染,而这些与服务端那个 409 前置毫无关系。可能
发生的是挂死:此时 finally 永不执行,图层锁与归属登记被永久持有,用户删掉占位
也没用——闸的另一半是静默 return,再点没有任何反应。

删除式修复:去掉 persistCover: false 覆盖、独立的 coverSave 与末尾的 await,
让封面回到 queue 内建的 fire-and-forget 分支。参数逐项等价,封面照存,只是不再
有人等它。未采纳加选项的方案,那会把同一问题留在图集拆分身上。

代价是 returnToProjects 不再等封面就跳转;该保护本就很薄——SPA 跳转不会打断
promise,真正打断它的是页面卸载,而 await 在 beforeunload 里同样救不了。

遗留:loadProjectCoverImage 里无界的 new Image() 仍是隐患,自动保存路径一样会
踩,本次只是把它移出生成链的关键路径。建议单开。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:43:40 +00:00

1664 lines
56 KiB
TypeScript

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,
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<typeof saveEditorProjectLayout>[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<string>;
}) {
const authoritativeById = new Map(
authoritativeItems.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [[id, item] as const] : [];
}),
);
const pendingIds = new Set<string>();
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<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
getCanvasGenerationDialogsSnapshot?: () => CanvasGenerationDialogState[];
canvasBackgroundColorRef: RefObject<string>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
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<CachedEditorProjectSnapshot>;
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: 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<string, EditorProjectResourceSnapshot>();
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<string | null>(null);
const projectRevisionRef = useRef<number | null>(null);
const currentUserIdRef = useRef(currentUserId);
currentUserIdRef.current = currentUserId;
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
canAccessProtectedDataRef.current = canAccessProtectedData;
const authoritativeProjectIdRef = useRef<string | null>(null);
const hasAuthoritativeProjectSnapshotRef = useRef(false);
const acceptedAuthoritativeSnapshotSequenceRef = useRef(0);
const authoritativeLayoutItemIdsRef = useRef<Set<string>>(new Set());
const lastAuthoritativeOwnerUserIdRef = useRef<string | null | undefined>(
undefined,
);
const lastAuthoritativeProjectIdRef = useRef<string | null>(null);
const lastAuthoritativeRevisionRef = useRef<number | null>(null);
const projectAuthorityEpochRef = useRef(0);
const applyProjectSnapshotRef = useRef<
| ((
project: EditorProjectSnapshot,
options?: ApplyProjectSnapshotOptions,
) => boolean)
| null
>(null);
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const pendingCreatedProjectResourceLayersRef = useRef<
PendingCreatedProjectResourceLayer[]
>([]);
const pendingProjectLayoutSaveRef = useRef<PendingProjectLayoutSave | null>(
null,
);
const isProjectLayoutSaveRunningRef = useRef(false);
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
const activeProjectLayoutSaveAttemptRef =
useRef<ActiveProjectLayoutSaveAttempt | null>(null);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
const coverSnapshotSignatureRef = useRef<string | null>(null);
const coverSnapshotUploadRequestRef = useRef(0);
const activeCoverSnapshotPersistenceRef = useRef<{
signature: string;
promise: Promise<void>;
} | null>(null);
const coverSnapshotViewportSizeRef = useRef(canvasSize);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
// 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位,
// 布尔只会提示一次。调用方以计数变化为触发条件。
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<typeof saveEditorProjectLayout>[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,
durationSeconds: layer.durationSeconds,
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<string, CanvasLayerResourceMetadata>(
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,
},
]),
);
// 中文注释:请求账本只在本机。读不到(换设备、清缓存、隐私模式)时占位会被
// hydrate 收口成可删除的失败态——这是明确设计,不阻断任何后续操作。
const localPerfectPixelOperations = readPerfectPixelOperations(
currentUserId,
project.projectId,
);
const { layerItems, generationDialogs, canvasBackgroundColor } =
splitCanvasLayoutItems(
appliedLayoutItems,
resourcesById,
currentUserId,
localPerfectPixelOperations,
);
// 中文注释: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<string, unknown>,
)
) {
savePerfectPixelOperation(
currentUserId,
project.projectId,
operation,
);
}
}
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
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) {
writeEditorProjectSessionCache(
pendingLocalLayout
? {
...project,
viewport: appliedViewport,
layers: appliedLayoutItems,
}
: project,
currentUserId,
project.canvas?.revision,
);
if (pendingLocalLayout) {
queueProjectLayoutSave(project.projectId, {
viewport: appliedViewport,
layers: appliedLayoutItems,
});
}
}
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,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
deadInlinePlaceholderDropCount,
};
}