dee91b6889
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>
1664 lines
56 KiB
TypeScript
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,
|
|
};
|
|
}
|