Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.ts
T
kdletters 0876361557 修正画布无效项目跳转
显式项目读取失败时返回项目页

403 和 404 项目访问错误不再停留在画布

补充无效项目 ID 回项目页测试
2026-06-25 23:44:36 +08:00

765 lines
22 KiB
TypeScript

import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { replaceAppHistoryPath } from '../../routing/appPageRoutes';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
import {
createEditorProjectResource,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
import {
canvasDisplayViewportToViewport,
type CanvasLayerResourceMetadata,
hydrateLayer,
isInlineEditorMediaSource,
serializeCanvasLayout,
splitCanvasLayoutItems,
viewportToCanvasDisplayViewport,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
import {
buildProjectCoverSnapshotSignature,
createProjectCoverSnapshotBlob,
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND,
PROJECT_COVER_SNAPSHOT_SIZE,
} from './ImageCanvasProjectCoverSnapshotModel';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
type PendingProjectResourceLayer = {
layer: CanvasLayer;
options: ProjectResourceOptions;
};
type PendingProjectLayoutSave = {
projectId: string;
input: Parameters<typeof saveEditorProjectLayout>[1];
};
type CachedEditorProjectSnapshot = {
project: EditorProjectSnapshot;
cachedAt: number;
};
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
);
}
function isEditorProjectAccessError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 403 || error.status === 404)
);
}
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.v1:';
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}recent`;
function getEditorProjectSessionStorage() {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
function editorProjectSessionCacheKey(projectId: string) {
return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent(projectId)}`;
}
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) {
const storage = getEditorProjectSessionStorage();
if (!storage) {
return null;
}
const key = projectId
? editorProjectSessionCacheKey(projectId)
: EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY;
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') {
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 cached.project;
} catch {
try {
storage.removeItem(key);
} catch {
// ignore storage cleanup errors
}
return null;
}
}
function removeEditorProjectSessionCache(projectId: string) {
const storage = getEditorProjectSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(editorProjectSessionCacheKey(projectId));
} catch {
// ignore storage cleanup errors
}
}
function writeEditorProjectSessionCache(project: EditorProjectSnapshot) {
if (projectSnapshotContainsInlineMedia(project)) {
return;
}
const storage = getEditorProjectSessionStorage();
if (!storage) {
return;
}
const cached: CachedEditorProjectSnapshot = {
project,
cachedAt: Date.now(),
};
try {
const serialized = JSON.stringify(cached);
storage.setItem(
editorProjectSessionCacheKey(project.projectId),
serialized,
);
storage.setItem(EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, 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,
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 skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
const coverSnapshotSignatureRef = useRef<string | null>(null);
const coverSnapshotUploadRequestRef = useRef(0);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
const {
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
} = setters;
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;
}
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 persistProjectCoverSnapshot = useCallback(
(
nextProjectId: string,
coverViewport: CanvasViewport,
coverLayers: readonly CanvasLayer[],
) => {
if (!canAccessProtectedData) {
return;
}
const signature = buildProjectCoverSnapshotSignature({
layers: coverLayers,
viewport: coverViewport,
});
if (!signature || coverSnapshotSignatureRef.current === signature) {
return;
}
coverSnapshotSignatureRef.current = signature;
coverSnapshotUploadRequestRef.current += 1;
const requestId = coverSnapshotUploadRequestRef.current;
void createProjectCoverSnapshotBlob({
layers: coverLayers,
viewport: coverViewport,
})
.then(async (blob) => {
if (!blob) {
if (coverSnapshotSignatureRef.current === signature) {
coverSnapshotSignatureRef.current = null;
}
return;
}
if (coverSnapshotUploadRequestRef.current !== requestId) {
return;
}
void putEditorProjectCoverCache({
projectId: nextProjectId,
blob,
});
const coverFile = new File([blob], `${nextProjectId}-cover.png`, {
type: 'image/png',
});
const uploadedCover = await uploadEditorMediaAssetFile(
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();
}
});
},
[canAccessProtectedData, openEditorLoginModal],
);
const queueProjectLayoutSave = useCallback(
(
nextProjectId: string,
input: Parameters<typeof saveEditorProjectLayout>[1],
options: { delayMs?: number } = {},
) => {
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);
}
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;
persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
runPendingProjectLayoutSave();
}, delayMs);
return;
}
persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
runPendingProjectLayoutSave();
},
[persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave],
);
const createProjectResourceForLayer = useCallback(
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
pendingProjectResourceLayersRef.current.push({ layer, options });
return;
}
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) => {
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) => {
clearPendingProjectLayoutSave();
skipNextProjectLayoutSaveRef.current = true;
if (projectIdRef.current !== project.projectId) {
coverSnapshotSignatureRef.current = null;
coverSnapshotUploadRequestRef.current += 1;
}
projectIdRef.current = project.projectId;
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<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);
},
[
clearPendingProjectLayoutSave,
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 cachedProject = readEditorProjectSessionCache(projectIdFromQuery);
if (cachedProject) {
applyProjectSnapshot(cachedProject);
}
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
}
applyProjectSnapshot(project);
writeEditorProjectSessionCache(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;
}
if (projectIdFromQuery && isEditorProjectAccessError(error)) {
removeEditorProjectSessionCache(projectIdFromQuery);
replaceAppHistoryPath('/project');
}
});
return () => {
cancelled = true;
};
}, [
canAccessProtectedData,
applyProjectSnapshot,
createProjectResourceForLayer,
openEditorLoginModal,
]);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
if (skipNextProjectLayoutSaveRef.current) {
skipNextProjectLayoutSaveRef.current = false;
persistProjectCoverSnapshot(
projectId,
viewportToCanvasDisplayViewport(viewport),
layers,
);
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,
persistProjectCoverSnapshot,
projectId,
queueProjectLayoutSave,
refs,
viewport,
]);
return {
projectId,
isProjectReady,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,
applyProjectSnapshot,
};
}