Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.ts
T
kdletters 47bdc73b93 串行化画板布局自动保存
让画板布局保存共用单飞队列避免慢 PATCH 并发

覆盖慢保存期间只发送最新待保存快照

补充发布入口 429 排查与自动保存文档
2026-06-23 15:16:50 +08:00

415 lines
12 KiB
TypeScript

import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorProjectResource,
loadEditorProject,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
canvasDisplayViewportToViewport,
type CanvasLayerResourceMetadata,
hydrateLayer,
serializeCanvasLayout,
splitCanvasLayoutItems,
viewportToCanvasDisplayViewport,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
type PendingProjectResourceLayer = {
layer: CanvasLayer;
options: ProjectResourceOptions;
};
type PendingProjectLayoutSave = {
projectId: string;
input: Parameters<typeof saveEditorProjectLayout>[1];
};
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
};
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;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
function isLocalProjectResourceId(resourceId: string) {
return resourceId.startsWith('local-');
}
function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
return `/${objectKey.replace(/^\/+/u, '')}`;
}
return layer.src;
}
export function useImageCanvasProjectPersistence({
refs,
setters,
layers,
canvasGenerationDialogs,
viewport,
canAccessProtectedData,
openEditorLoginModal,
}: {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
}) {
const projectIdRef = useRef<string | null>(null);
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const pendingProjectLayoutSaveRef =
useRef<PendingProjectLayoutSave | null>(null);
const isProjectLayoutSaveRunningRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
const {
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
} = setters;
const runPendingProjectLayoutSave = useCallback(
function runPendingProjectLayoutSave() {
if (isProjectLayoutSaveRunningRef.current) {
return;
}
const pendingSave = pendingProjectLayoutSaveRef.current;
if (!pendingSave) {
return;
}
pendingProjectLayoutSaveRef.current = null;
isProjectLayoutSaveRunningRef.current = true;
Promise.resolve(
saveEditorProjectLayout(pendingSave.projectId, pendingSave.input),
)
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
})
.finally(() => {
isProjectLayoutSaveRunningRef.current = false;
if (
pendingProjectLayoutSaveRef.current &&
saveTimerRef.current === null
) {
runPendingProjectLayoutSave();
}
});
},
[openEditorLoginModal],
);
const queueProjectLayoutSave = useCallback(
(
nextProjectId: string,
input: Parameters<typeof saveEditorProjectLayout>[1],
options: { delayMs?: number } = {},
) => {
pendingProjectLayoutSaveRef.current = {
projectId: nextProjectId,
input,
};
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const delayMs = options.delayMs ?? 0;
if (delayMs > 0) {
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
runPendingProjectLayoutSave();
}, delayMs);
return;
}
runPendingProjectLayoutSave();
},
[runPendingProjectLayoutSave],
);
const createProjectResourceForLayer = useCallback(
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
pendingProjectResourceLayersRef.current.push({ layer, options });
return;
}
createEditorProjectResource(readyProjectId, {
imageSrc: resolveProjectResourceCreateImageSrc(layer),
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) => {
options.onCreated?.(resource.resourceId);
const layerWithResourceId = {
...layer,
resourceId: resource.resourceId,
};
const currentLayers = refs.layersRef.current;
const nextLayers = currentLayers.some(
(currentLayer) => currentLayer.id === layer.id,
)
? currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? layerWithResourceId
: currentLayer,
)
: options.snapshotLayers?.some(
(snapshotLayer) => snapshotLayer.id === layer.id,
)
? options.snapshotLayers.map((snapshotLayer) =>
snapshotLayer.id === layer.id
? layerWithResourceId
: snapshotLayer,
)
: currentLayers;
refs.layersRef.current = nextLayers;
setLayers(nextLayers);
if (nextLayers.length) {
queueProjectLayoutSave(readyProjectId, {
viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current),
layers: serializeCanvasLayout({
layers: nextLayers,
canvasGenerationDialogs:
refs.canvasGenerationDialogsRef.current,
}),
});
}
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
},
[openEditorLoginModal, queueProjectLayoutSave, refs, setLayers],
);
const applyProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
projectIdRef.current = project.projectId;
setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布';
setProjectTitle(nextProjectTitle);
setProjectRenameValue(nextProjectTitle);
const nextViewport = canvasDisplayViewportToViewport(project.viewport);
setViewport(nextViewport);
refs.viewportRef.current = nextViewport;
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
project.resources.map((resource) => [
resource.resourceId,
{
imageSrc: resource.imageSrc,
resourceId: resource.resourceId,
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 } = splitCanvasLayoutItems(
project.layers,
resourcesById,
);
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);
},
[
refs,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayerCounter,
setLayers,
setProjectRenameValue,
setProjectTitle,
setViewport,
],
);
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(() => {
if (!canAccessProtectedData) {
setIsProjectReady(false);
return undefined;
}
let cancelled = false;
const projectIdFromQuery =
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search)
.get('projectid')
?.trim() || null;
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
}
applyProjectSnapshot(project);
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
pendingLayers.forEach(({ layer, options }) => {
createProjectResourceForLayer(layer, options);
});
setIsProjectReady(true);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setIsProjectReady(false);
if (isEditorAuthError(error)) {
openEditorLoginModal(() => {
window.location.reload();
});
}
});
return () => {
cancelled = true;
};
}, [
canAccessProtectedData,
applyProjectSnapshot,
createProjectResourceForLayer,
openEditorLoginModal,
]);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
queueProjectLayoutSave(
projectId,
{
viewport: viewportToCanvasDisplayViewport(viewport),
layers: serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}),
},
{ delayMs: 450 },
);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [
isProjectReady,
canvasGenerationDialogs,
layers,
projectId,
queueProjectLayoutSave,
refs,
viewport,
]);
return {
projectId,
isProjectReady,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,
applyProjectSnapshot,
};
}