import { canvasDisplayScaleToViewportScale, clamp, resolveSnappedItemPosition, viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; import { EDITOR_SOUND_EFFECT_MODEL, SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, } from '../../../packages/shared/src/contracts/editorAudio'; import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene'; import type { EditorAssetGenerationInputs, EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, EditorPixelArtSnapInput, EditorProjectLayerSnapshot, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, CanvasContextMenuState, CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, CanvasMediaType, CanvasViewport, CharacterReferenceImage, EditorAsset, EditorAssetFolder, PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; import { hydrateCanvasGenerationInputs, hydrateCanvasGenerationInputsResult, } from './ImageCanvasGenerationInputsModel'; export { CANVAS_DISPLAY_SCALE_BASE, CANVAS_WORLD_ORIGIN, CANVAS_WORLD_SIZE, canvasDisplayScaleToViewportScale, clamp, DEFAULT_CANVAS_SIZE, findNearestSnap, FIT_VIEW_PADDING, formatCanvasDisplayScalePercent, formatPercent, getLayerBounds, MAX_HISTORY_STEPS, MAX_SCALE, MIN_SCALE, MINIMAP_DRAG_SENSITIVITY, MINIMAP_PADDING, MINIMAP_SIZE, resolveSnappedItemPosition, SNAP_DISTRIBUTION_OVERLAP_TOLERANCE, SNAP_THRESHOLD_SCREEN_PX, viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: false, }, ]; export const TOOLBAR_HALF_WIDTH = 132; export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([ 'anime-seg', 'bgfilter complex', 'birefnet', 'connected-components', 'screen-color-keying', 'segment-common-image', ]); export function isEditorInternalProcessingModel( model: string | null | undefined, ) { const normalizedModel = model?.trim().toLowerCase(); return Boolean( normalizedModel && INTERNAL_EDITOR_PROCESSING_MODELS.has(normalizedModel), ); } export const CONTEXT_MENU_VIEWPORT_MARGIN = 8; export const CONTEXT_MENU_SIZE = { blank: { width: 188, height: 176 }, layer: { width: 188, height: 492 }, 'generation-dialog': { width: 188, height: 64 }, } as const; export const CANVAS_BACKGROUND_OPTIONS = [ { label: '白色', value: '#ffffff' }, { label: '浅灰', value: '#f8fafc' }, { label: '暖灰', value: '#f3f0ea' }, { label: '冷蓝', value: '#eef6ff' }, ]; export const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; export function normalizeCanvasBackgroundHex(value: string) { const trimmedValue = value.trim().toLowerCase(); const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue); if (!match) { return null; } const hexValue = match[1] ?? ''; if (hexValue.length === 3) { return `#${hexValue .split('') .map((part) => `${part}${part}`) .join('')}`; } return `#${hexValue}`; } export function viewportToCanvasDisplayViewport( viewport: CanvasViewport, ): CanvasViewport { return { ...viewport, scale: viewportScaleToCanvasDisplayScale(viewport.scale), }; } export function canvasDisplayViewportToViewport( viewport: CanvasViewport, ): CanvasViewport { return { ...viewport, scale: canvasDisplayScaleToViewportScale(viewport.scale), }; } export function formatImageSizeValue(width: number, height: number) { const safeWidth = Math.max(1, Math.round(width || 1024)); const safeHeight = Math.max(1, Math.round(height || 1024)); return `${safeWidth}x${safeHeight}`; } export function resolveLayerResolutionSize( originalWidth: number, originalHeight: number, fallback: { width: number; height: number }, ) { // 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。 return { width: Math.max(1, Math.round(originalWidth || fallback.width || 1)), height: Math.max(1, Math.round(originalHeight || fallback.height || 1)), }; } export function createLayerFromAsset( asset: EditorAsset, index: number, viewport: CanvasViewport, screenCenter: { x: number; y: number }, options: { applyCascadeOffset?: boolean } = {}, ): CanvasLayer { assertCompleteImageSequenceAsset(asset); const { width, height } = resolveLayerResolutionSize( asset.width, asset.height, { width: 360, height: 360 }, ); const safeScale = viewport.scale > 0 ? viewport.scale : 1; const safeScreenCenter = { x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0, y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0, }; const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; const offset = options.applyCascadeOffset === false ? 0 : index * 34; const assetKind = inferEditorAssetKind( asset.label, asset.src, asset.objectKey, asset.assetKind, asset.mediaType, ); const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: `layer-${asset.id}-${index}`, resourceId: `local-resource-${asset.id}-${index}`, resourcePersistenceState: 'pending', title: asset.label, src: asset.src, mediaType, x: worldCenterX - width / 2 + offset, y: worldCenterY - height / 2 + offset, width, height, originalWidth: asset.width, originalHeight: asset.height, zIndex: index + 10, sourceType: asset.sourceType, thumbnailSrc: asset.thumbnailSrc, prompt: asset.prompt, actualPrompt: asset.actualPrompt, model: asset.model, provider: asset.provider, taskId: asset.taskId, objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, sourceResourceId: asset.sourceResourceId, sourceAssetId: asset.id, resourceAssetKind: asset.assetKind ?? assetKind ?? null, assetKindOverride: null, assetKind: asset.assetKind ?? assetKind, generationInputs: asset.generationInputs, generationInputsHydrationState: asset.generationInputsHydrationState, imageSequenceFrames: asset.imageSequenceFrames, imageSequenceDurationMs: asset.imageSequenceDurationMs, } satisfies CanvasLayer; } export function assertCompleteImageSequenceAsset(asset: EditorAsset) { if ( asset.assetKind === 'character-animation' && (asset.imageSequenceFrames?.length ?? 0) < 2 ) { throw new Error(`角色动作素材“${asset.label}”不完整:缺少可用序列帧。`); } if ( asset.assetKind === 'character-animation' && !(asset.imageSequenceDurationMs && asset.imageSequenceDurationMs > 0) ) { throw new Error(`角色动作素材“${asset.label}”不完整:缺少序列播放时长。`); } } export function isInlineEditorMediaSource(value: string | null | undefined) { const normalizedValue = value?.trim().toLowerCase() ?? ''; return ( normalizedValue.startsWith('data:image/') || normalizedValue.startsWith('data:video/') || normalizedValue.startsWith('data:audio/') || normalizedValue.startsWith('blob:') ); } function isSignedEditorMediaSource(value: string | null | undefined) { const normalizedValue = value?.trim().toLowerCase() ?? ''; return ( /^(?:https?:)?\/\//u.test(normalizedValue) && (normalizedValue.includes('x-oss-signature') || normalizedValue.includes('x-oss-credential') || normalizedValue.includes('expires=') || normalizedValue.includes('signature=')) ); } function normalizeObjectKeyPath(objectKey: string | null | undefined) { const normalizedObjectKey = objectKey?.trim().replace(/^\/+/u, '') ?? ''; return normalizedObjectKey ? `/${normalizedObjectKey}` : undefined; } function serializeOptionalMediaSource( source: string | null | undefined, objectKey?: string | null, ) { const normalizedSource = source?.trim() ?? ''; if (!normalizedSource) { return undefined; } if ( isInlineEditorMediaSource(normalizedSource) || isSignedEditorMediaSource(normalizedSource) ) { return normalizeObjectKeyPath(objectKey); } return normalizedSource; } function serializeImageSequenceFrames( frames: CanvasLayer['imageSequenceFrames'], ) { if (!frames?.length) { return undefined; } const serializedFrames = frames.flatMap((frame) => { const imageSrc = serializeOptionalMediaSource( frame.imageSrc, frame.objectKey, ); return imageSrc ? [ { ...frame, imageSrc, }, ] : []; }); return serializedFrames.length ? serializedFrames : undefined; } /** * 中文注释:`model` / `provider` 只有在图层挂着项目资源行时才交给资源行权威持有。 * * 有资源行:服务端读边界会脱敏内部处理模型(`model`)并无条件省略 `provider`,客户端拿到的 * `layer.model` 是脱敏后按来源链推导出的展示值,回写必然与资源行原值冲突,被结构化保存判为 * 「与项目资源不一致」而整次 400。保存时服务端本就会剥离这两个字段,也不参与画布布局哈希, * 因此直接不发。 * * 没有资源行(自包含的 legacy 本地图片序列,例如角色动画逐帧层):服务端 * `normalize_structured_canvas_layer_against_resource` 走的是 `resource == None` 早退分支, * 只摘掉 `assetKind` 就把 item 原样写回,item_json 是这些元数据的**唯一**存储。此时停发会让 * 模型信息在下一次保存后永久丢失,图片信息、ZIP 导出与快速编辑默认模型一起静默退化。 */ function serializeResourceOwnedModelFields(layer: CanvasLayer) { if (layer.resourcePersistenceState === 'registered') { return {}; } return { model: layer.model, provider: layer.provider }; } export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { const isFormalCharacterAnimation = layer.assetKind === 'character-animation' && layer.resourcePersistenceState !== 'self-contained-local'; return { layerId: layer.id, resourceId: layer.resourceId, title: layer.title, x: layer.x, y: layer.y, width: layer.width, height: layer.height, originalWidth: layer.originalWidth, originalHeight: layer.originalHeight, zIndex: layer.zIndex, ...(isFormalCharacterAnimation ? {} : { sourceType: layer.sourceType, mediaType: layer.mediaType, thumbnailSrc: serializeOptionalMediaSource( layer.thumbnailSrc, layer.objectKey, ), imageSequenceFrames: serializeImageSequenceFrames( layer.imageSequenceFrames, ), previewVideoPath: serializeOptionalMediaSource( layer.previewVideoPath, layer.objectKey, ), prompt: layer.prompt, actualPrompt: layer.actualPrompt, ...serializeResourceOwnedModelFields(layer), taskId: layer.taskId, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, sourceResourceId: layer.sourceResourceId, sourceAssetId: layer.sourceAssetId, }), groupId: layer.groupId, assetKindOverride: layer.assetKindOverride ?? null, hidden: layer.hidden, locked: layer.locked, flipX: layer.flipX, flipY: layer.flipY, }; } type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & { itemType: 'generation-dialog'; dialog: CanvasGenerationDialogState; }; type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & { itemType: 'canvas-settings'; canvasBackgroundColor?: string; }; export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-'; // 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 75 秒绝对窗口。截止时间随 // durable operation 持久化并在 hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在 // “从当前最多再观察一个窗口”。POST 回包与素材刷新都不能替同一次 operation 续期。 export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 75_000; // 中文注释:第一批曾把 v1 快照写成 240 秒。滚动部署与旧标签页仍可能持久化该形状, // 所以读取侧保留兼容上限;它只决定快照是否可信,不会延长当前 75 秒对账窗口。 const LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000; const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE = '完美像素操作快照无效,禁止自动重试。'; const PERFECT_PIXEL_OPERATION_KEYS = new Set([ 'version', 'kind', 'operationId', 'taskId', 'request', 'submittedAt', 'reconcileUntil', ]); const PERFECT_PIXEL_REQUEST_KEYS = new Set([ 'sourceImageSrc', 'projectId', 'sourceResourceId', 'assetKind', 'generationInputs', 'assetFolderId', 'assetLabel', 'canvasCompletion', ]); const PERFECT_PIXEL_COMPLETION_KEYS = new Set([ 'dialogId', 'title', 'placeholder', ]); const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([ 'x', 'y', 'width', 'height', 'originalWidth', 'originalHeight', ]); function isSnapshotRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function hasOnlySnapshotKeys( value: Record, allowedKeys: ReadonlySet, ) { return Object.keys(value).every((key) => allowedKeys.has(key)); } function isOptionalNullableString(value: unknown) { return value === undefined || value === null || typeof value === 'string'; } function isStableEditorMediaReference(value: unknown): value is string { if (typeof value !== 'string' || !value.trim()) { return false; } const normalized = value.trimStart().toLowerCase(); if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { return false; } try { const url = new URL(value); if (url.protocol !== 'http:' && url.protocol !== 'https:') { return true; } const signedQueryKeys = new Set([ 'expires', 'signature', 'x-amz-signature', 'x-amz-security-token', 'x-oss-signature', 'x-oss-security-token', ]); return [...url.searchParams.keys()].every( (key) => !signedQueryKeys.has(key.toLowerCase()), ); } catch { return true; } } /** * 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。 * 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份; * 任何未知版本、字段漂移或不稳定媒体引用都失败关闭,绝不能据当前画布状态猜测并重放 POST。 */ export function hydratePerfectPixelOperation( value: unknown, dialogId: string, ): PerfectPixelOperationSnapshot | null { const now = Date.now(); if ( !isSnapshotRecord(value) || !hasOnlySnapshotKeys(value, PERFECT_PIXEL_OPERATION_KEYS) || value.version !== 1 || value.kind !== 'perfect-pixel' || value.operationId !== dialogId || value.taskId !== `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}` || typeof value.submittedAt !== 'number' || !Number.isFinite(value.submittedAt) || value.submittedAt <= 0 || typeof value.reconcileUntil !== 'number' || !Number.isFinite(value.reconcileUntil) || value.reconcileUntil < value.submittedAt || value.reconcileUntil - value.submittedAt > LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS || value.submittedAt > now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ) { return null; } const normalizedSubmittedAt = Math.min(value.submittedAt, now); const request = value.request; if ( !isSnapshotRecord(request) || !hasOnlySnapshotKeys(request, PERFECT_PIXEL_REQUEST_KEYS) || !isStableEditorMediaReference(request.sourceImageSrc) || typeof request.projectId !== 'string' || !request.projectId.trim() || !isOptionalNullableString(request.sourceResourceId) || !isOptionalNullableString(request.assetKind) || !isOptionalNullableString(request.assetFolderId) || !isOptionalNullableString(request.assetLabel) ) { return null; } const completion = request.canvasCompletion; if ( !isSnapshotRecord(completion) || !hasOnlySnapshotKeys(completion, PERFECT_PIXEL_COMPLETION_KEYS) || completion.dialogId !== dialogId || typeof completion.title !== 'string' ) { return null; } const placeholder = completion.placeholder; if ( !isSnapshotRecord(placeholder) || !hasOnlySnapshotKeys(placeholder, PERFECT_PIXEL_PLACEHOLDER_KEYS) || ![...PERFECT_PIXEL_PLACEHOLDER_KEYS].every( (key) => typeof placeholder[key] === 'number' && Number.isFinite(placeholder[key]), ) ) { return null; } let generationInputs: EditorAssetGenerationInputs | null | undefined; if (request.generationInputs === undefined) { generationInputs = undefined; } else if (request.generationInputs === null) { generationInputs = null; } else { generationInputs = hydrateCanvasGenerationInputs(request.generationInputs, { strictWhitelist: true, }); } if ( request.generationInputs !== undefined && request.generationInputs !== null && !generationInputs ) { return null; } const hydratedRequest: EditorPixelArtSnapInput = { sourceImageSrc: request.sourceImageSrc, projectId: request.projectId, ...(request.sourceResourceId !== undefined ? { sourceResourceId: request.sourceResourceId as string | null, } : {}), ...(request.assetKind !== undefined ? { assetKind: request.assetKind as string | null } : {}), ...(generationInputs !== undefined ? { generationInputs } : {}), ...(request.assetFolderId !== undefined ? { assetFolderId: request.assetFolderId as string | null } : {}), ...(request.assetLabel !== undefined ? { assetLabel: request.assetLabel as string | null } : {}), canvasCompletion: { dialogId, title: completion.title, placeholder: { x: placeholder.x as number, y: placeholder.y as number, width: placeholder.width as number, height: placeholder.height as number, originalWidth: placeholder.originalWidth as number, originalHeight: placeholder.originalHeight as number, }, }, }; return { version: 1, kind: 'perfect-pixel', operationId: dialogId, taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`, request: hydratedRequest, // 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法; // 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留 // 当前 75 秒绝对窗口。 submittedAt: normalizedSubmittedAt, reconcileUntil: Math.min( value.reconcileUntil, normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, ), }; } function isPersistedReferenceResourceId(resourceId: string | null | undefined) { const normalizedResourceId = resourceId?.trim(); return Boolean( normalizedResourceId && !normalizedResourceId.startsWith('local-') && !normalizedResourceId.startsWith('generation-dialog:'), ); } function isPersistedReferenceAssetId(assetId: string | null | undefined) { const normalizedAssetId = assetId?.trim(); return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-')); } function normalizedCurrentUserId(userId: string | null | undefined) { return userId?.trim() || null; } function isReferencePointerSrc(src: string | null | undefined) { const normalizedSrc = src?.trim() ?? ''; return ( normalizedSrc.startsWith('ref:project-resource:') || normalizedSrc.startsWith('ref:asset:') ); } function resolveReferencePointer(reference: CharacterReferenceImage) { const resourceId = reference.resourceId?.trim(); if (isPersistedReferenceResourceId(resourceId)) { return { src: `ref:project-resource:${resourceId}`, resourceId, sourceAssetId: reference.sourceAssetId, }; } const sourceAssetId = reference.sourceAssetId?.trim(); if (isPersistedReferenceAssetId(sourceAssetId)) { return { src: `ref:asset:${sourceAssetId}`, resourceId: reference.resourceId, sourceAssetId, }; } return null; } function serializeGenerationReference( reference: CharacterReferenceImage | null | undefined, ): CharacterReferenceImage | null { if (!reference) { return null; } const pointer = resolveReferencePointer(reference); if (!pointer) { return null; } return { id: reference.id, label: reference.label, src: pointer.src, mediaType: reference.mediaType, mimeType: reference.mimeType, sizeBytes: reference.sizeBytes, durationSeconds: reference.durationSeconds, resourceId: pointer.resourceId, sourceAssetId: pointer.sourceAssetId, }; } function serializeGenerationReferences( references: CharacterReferenceImage[] | undefined, ) { if (!references) { return undefined; } return references.flatMap((reference) => { const serialized = serializeGenerationReference(reference); return serialized ? [serialized] : []; }); } /** * 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。 * * 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由 * `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则 * 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。 * * 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功 * 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。 */ function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { const { perfectPixelOperation, ...persistedDialog } = dialog; const perfectPixelOperationId = isSettledPerfectPixelDialogRecord( dialog as unknown as Record, ) ? undefined : (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId); return { ...persistedDialog, ...(perfectPixelOperationId ? { perfectPixelOperationId } : {}), specReference: serializeGenerationReference(dialog.specReference), generationReferences: serializeGenerationReferences( dialog.generationReferences, ), characterSpecReference: serializeGenerationReference( dialog.characterSpecReference, ), characterReferences: serializeGenerationReferences( dialog.characterReferences, ), iconSpecReference: serializeGenerationReference(dialog.iconSpecReference), publicationReferences: serializeGenerationReferences( dialog.publicationReferences, ), uiDesignSpecReference: serializeGenerationReference( dialog.uiDesignSpecReference, ), }; } export function serializeCanvasGenerationDialog( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogSnapshot { return { itemType: 'generation-dialog', layerId: `generation-dialog:${dialog.id}`, resourceId: `generation-dialog:${dialog.id}`, dialog: serializeDialogReferences(dialog), }; } function serializeCanvasSettings({ canvasBackgroundColor, }: { canvasBackgroundColor?: string | null; }): CanvasSettingsLayoutSnapshot | null { const normalizedBackgroundColor = canvasBackgroundColor ? normalizeCanvasBackgroundHex(canvasBackgroundColor) : null; if (!normalizedBackgroundColor) { return null; } return { itemType: 'canvas-settings', layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID, resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID, canvasBackgroundColor: normalizedBackgroundColor, }; } export function serializeCanvasLayout({ layers, canvasGenerationDialogs, canvasBackgroundColor, }: { layers: CanvasLayer[]; canvasGenerationDialogs: CanvasGenerationDialogState[]; canvasBackgroundColor?: string | null; }): CanvasLayoutItems { const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor }); return [ ...(canvasSettings ? [canvasSettings] : []), ...layers.map(serializeLayer), ...canvasGenerationDialogs.map(serializeCanvasGenerationDialog), ]; } export function isCanvasGenerationDialogLayoutItem( item: EditorProjectLayerSnapshot, ): item is CanvasGenerationDialogSnapshot { return ( item.itemType === 'generation-dialog' && Boolean( hydrateCanvasGenerationDialog((item as { dialog?: unknown }).dialog), ) ); } function isCanvasSettingsLayoutItem( item: EditorProjectLayerSnapshot, ): item is CanvasSettingsLayoutSnapshot { return item.itemType === 'canvas-settings'; } /** * 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组。 * * 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值 * (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。 */ export function findCanvasGenerationDialogRecords( project: EditorProjectSnapshot, dialogId: string | null | undefined, ): Record[] { const normalizedDialogId = dialogId?.trim(); if (!normalizedDialogId) { return []; } const matchingDialogs: Record[] = []; for (const item of project.layers) { if (item.itemType !== 'generation-dialog') { continue; } const dialog = (item as { dialog?: unknown }).dialog && typeof (item as { dialog?: unknown }).dialog === 'object' ? ((item as { dialog?: unknown }).dialog as Record) : null; if (dialog?.id === normalizedDialogId) { matchingDialogs.push(dialog); } } return matchingDialogs; } /** * 中文注释:占位是否仍未收口。这是本仓库对「这个生成完成了没有」的既有定义,原先只存在于 * `useImageCanvasGenerationSubmissionWorkflow` 的队列轮询里,现在提取共用。 * * 判据必须是 status / generatedLayerId,**不能**是「占位还在不在」。服务端成功回填时会保留 * 该 dialog 并就地改写(`editor_project.rs` 的 `apply_editor_canvas_generation_items`:置 * `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`), * 该行为另有服务端测试钉住。按「在不在」判会把真成功判成失败。 */ export function isUnresolvedCanvasGenerationDialogRecord( dialog: Record | null, ): boolean { if (!dialog) { return false; } return ( dialog.status === 'generating' || dialog.status === 'pending-confirmation' || typeof dialog.generatedLayerId !== 'string' || dialog.generatedLayerId.trim() === '' ); } /** * 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。 * * **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的 * `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、 * 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本 * ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时 * 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。 */ function isSettledPerfectPixelDialogRecord( dialog: Record | null, ): boolean { return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog); } /** * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 * * 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由 * `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。 * 客户端整条链的上界是提交前置预算 90 秒加 POST 120 秒 = 210 秒,之后必已 abort 并把占位改成 * `failed` 或直接移除,服务端也早已越过自己的 90 秒。取 240 秒 = 210 秒客户端上界 + 30 秒余量 * (网络往返、标签页被挂起后的时钟漂移)。 * * 本会话自己的占位另有归属登记豁免,不依赖这个窗口;窗口只用于跨标签页——B 标签不得清掉 * A 标签仍在合法执行的占位,所以它必须大于 A 的客户端上界。 */ export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000; function inlineGenerationPlaceholderExpiryAt( dialog: CanvasGenerationDialogState, ): number | null { if ( dialog.perfectPixelOperation || dialog.requiresLiveSession !== true || dialog.status !== 'generating' ) { return null; } const startedAt = dialog.generationStartedAt; // 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底 // 方向一致——按保留会让这类占位永久转下去。 if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { return Number.NEGATIVE_INFINITY; } return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; } /** * 中文注释:内存态里已经越过存活窗口的 inline 占位 id。 * * 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、 * 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。 * 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户 * 下一次加载——那正是引入存活窗口带来的回归。 */ export function collectExpiredInlineGenerationDialogIds( dialogs: readonly CanvasGenerationDialogState[], now: number = Date.now(), ): string[] { return dialogs .filter((dialog) => { const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); return expiryAt !== null && now > expiryAt; }) .map((dialog) => dialog.id); } /** * 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器, * 而不是轮询——到期时刻是可以精确算出来的。 */ export function resolveNextInlineGenerationDialogExpiryAt( dialogs: readonly CanvasGenerationDialogState[], ): number | null { let earliest: number | null = null; for (const dialog of dialogs) { const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); if (expiryAt === null || !Number.isFinite(expiryAt)) { continue; } if (earliest === null || expiryAt < earliest) { earliest = expiryAt; } } return earliest; } /** * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 * * 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端 * 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到 * A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前 * revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。) * * 旧 inline 占位改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` * 才判定为孤儿。带 `perfectPixelOperation` 请求账本的新占位不再走这条 legacy 清理,而由 * GET-only 对账收口;即便账本损坏也要留给 hydrate 失败关闭,不能在这里静默删卡。 * * 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移 * 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 * * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 */ function canvasGenerationDialogRecord( item: EditorProjectLayerSnapshot, ): Record | null { if (item.itemType !== 'generation-dialog') { return null; } const dialog = (item as { dialog?: unknown }).dialog; return isSnapshotRecord(dialog) ? dialog : null; } function canvasGenerationDialogMirrorKey(item: EditorProjectLayerSnapshot) { const dialogId = stringOrNull(canvasGenerationDialogRecord(item)?.id); return dialogId ? `dialog:${dialogId}` : `layer:${item.layerId}`; } function isDeadLegacyInlineGenerationPlaceholder( item: EditorProjectLayerSnapshot, now: number, ) { const dialog = canvasGenerationDialogRecord(item); if (dialog?.requiresLiveSession !== true || dialog.status !== 'generating') { return false; } const startedAt = dialog.generationStartedAt; // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 // `generationStartedAt` 在同一次创建里一起写——但 legacy 兜底仍需避免永久孤儿。 return ( typeof startedAt !== 'number' || !Number.isFinite(startedAt) || now - startedAt > INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS ); } export function dropDeadInlineGenerationPlaceholders( project: EditorProjectSnapshot, now: number = Date.now(), ): { project: EditorProjectSnapshot; droppedCount: number } { const mirroredLayers = [...project.layers, ...(project.canvas?.layers ?? [])]; const operationBackedDialogKeys = new Set( mirroredLayers.flatMap((item) => { const dialog = canvasGenerationDialogRecord(item); // 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出 // 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。 return dialog && (Object.prototype.hasOwnProperty.call( dialog, 'perfectPixelOperationId', ) || Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation')) ? [canvasGenerationDialogMirrorKey(item)] : []; }), ); const droppedDialogKeys = new Set( mirroredLayers.flatMap((item) => { const key = canvasGenerationDialogMirrorKey(item); return !operationBackedDialogKeys.has(key) && isDeadLegacyInlineGenerationPlaceholder(item, now) ? [key] : []; }), ); const droppedCount = droppedDialogKeys.size; if (droppedCount === 0) { return { project, droppedCount: 0 }; } const dropFromMirror = (layers: EditorProjectLayerSnapshot[]) => layers.filter( (item) => item.itemType !== 'generation-dialog' || !droppedDialogKeys.has(canvasGenerationDialogMirrorKey(item)), ); return { project: { ...project, layers: dropFromMirror(project.layers), ...(project.canvas ? { canvas: { ...project.canvas, layers: dropFromMirror(project.canvas.layers), }, } : {}), }, droppedCount, }; } export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), currentUserId?: string | null, localPerfectPixelOperations?: ReadonlyMap< string, PerfectPixelOperationSnapshot >, ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; canvasBackgroundColor?: string; } { const layerItems: EditorProjectLayerSnapshot[] = []; const generationDialogs: CanvasGenerationDialogState[] = []; let canvasBackgroundColor: string | undefined; items.forEach((item) => { if (isCanvasSettingsLayoutItem(item)) { const normalizedBackgroundColor = typeof item.canvasBackgroundColor === 'string' ? normalizeCanvasBackgroundHex(item.canvasBackgroundColor) : null; if (normalizedBackgroundColor) { canvasBackgroundColor = normalizedBackgroundColor; } return; } if (isCanvasGenerationDialogLayoutItem(item)) { const dialog = hydrateCanvasGenerationDialog( item.dialog, resourcesById, currentUserId, localPerfectPixelOperations, ); if (dialog) { generationDialogs.push(dialog); } return; } layerItems.push(item); }); return { layerItems, generationDialogs, canvasBackgroundColor }; } export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, localPerfectPixelOperations?: ReadonlyMap< string, PerfectPixelOperationSnapshot >, ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; } const snapshot = value as Partial; const id = stringOrNull(snapshot.id); const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : ''; if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } // 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里, // 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。 const hasLegacyInlinePerfectPixelOperation = Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation'); const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation ? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id) : null; const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call( snapshot, 'perfectPixelOperationId', ); const isPerfectPixelPlaceholder = hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId; const perfectPixelOperation = legacyInlinePerfectPixelOperation ?? // 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按 // 当前画布状态猜一条请求出来重放。 (declaredPerfectPixelOperationId && stringOrNull(snapshot.perfectPixelOperationId) === id ? (localPerfectPixelOperations?.get(id) ?? null) : null); const hasPersistedInvalidPerfectPixelOperationMarker = Object.prototype.hasOwnProperty.call( snapshot, 'perfectPixelOperationInvalid', ); // 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId` // 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面 // 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。 // 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。 const isSettledPerfectPixelPlaceholder = isPerfectPixelPlaceholder && isSettledPerfectPixelDialogRecord(snapshot as Record); // 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的 // 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 const hasInvalidPerfectPixelOperation = !isSettledPerfectPixelPlaceholder && ((isPerfectPixelPlaceholder && !perfectPixelOperation) || hasPersistedInvalidPerfectPixelOperationMarker || (snapshot.status === 'pending-confirmation' && !perfectPixelOperation)); const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation ? undefined : perfectPixelOperation; const style = snapshot.mode === 'generate' || snapshot.mode === 'character' || snapshot.mode === 'icon' ? snapshot.style === 'pixelArt' ? 'pixelArt' : 'none' : undefined; return { id, mode: snapshot.mode, prompt, status: hasInvalidPerfectPixelOperation ? 'failed' : // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是 // `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。 isSettledPerfectPixelPlaceholder ? 'idle' : isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, // 不会被 dropDeadInlineGenerationPlaceholders 误清。 requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined, ...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder ? { perfectPixelOperationId: id } : {}), ...(trustedPerfectPixelOperation ? { perfectPixelOperation: trustedPerfectPixelOperation } : {}), ...(hasInvalidPerfectPixelOperation ? { perfectPixelOperationInvalid: true } : {}), composerOpen: typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true, sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), generatedLayerId: stringOrUndefined(snapshot.generatedLayerId), specType: normalizeSpecGenerationType(snapshot.specType), specValues: hydrateSpecFormValues(snapshot.specValues), specReference: hydrateCharacterReference( snapshot.specReference, resourcesById, currentUserId, ), generationReferences: hydrateCharacterReferences( snapshot.generationReferences, resourcesById, currentUserId, ), characterSpecReference: hydrateCharacterReference( snapshot.characterSpecReference, resourcesById, currentUserId, ), characterReferences: hydrateCharacterReferences( snapshot.characterReferences, resourcesById, currentUserId, ), iconSpecReference: hydrateCharacterReference( snapshot.iconSpecReference, resourcesById, currentUserId, ), iconDescriptions: Array.isArray(snapshot.iconDescriptions) ? snapshot.iconDescriptions.filter( (description): description is string => typeof description === 'string', ) : undefined, publicationWorkflowId: hydratePublicationWorkflowId( snapshot.publicationWorkflowId, ), publicationGameInfo: hydratePublicationGameInfo( snapshot.publicationGameInfo, ), publicationReferences: hydrateCharacterReferences( snapshot.publicationReferences, resourcesById, currentUserId, ), uiDesignSpecReference: hydrateCharacterReference( snapshot.uiDesignSpecReference, resourcesById, currentUserId, ), sceneStylePreset: isEditorSceneStylePreset(snapshot.sceneStylePreset) ? snapshot.sceneStylePreset : undefined, sceneCustomStyle: stringOrUndefined(snapshot.sceneCustomStyle), imageModel: stringOrUndefined(snapshot.imageModel), style, videoModel: typeof snapshot.videoModel === 'string' ? snapshot.videoModel : undefined, videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio), videoResolution: snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p' || snapshot.videoResolution === '1080p' ? snapshot.videoResolution : undefined, videoDurationSeconds: typeof snapshot.videoDurationSeconds === 'number' ? Math.min(15, Math.max(4, Math.round(snapshot.videoDurationSeconds))) : undefined, videoMode: snapshot.videoMode === 'std' ? 'std' : undefined, videoSound: snapshot.videoSound === 'on' || snapshot.videoSound === 'off' ? snapshot.videoSound : undefined, videoWebSearchEnabled: typeof snapshot.videoWebSearchEnabled === 'boolean' ? snapshot.videoWebSearchEnabled : undefined, characterAnimationResolution: snapshot.characterAnimationResolution === '480p' || snapshot.characterAnimationResolution === '720p' ? snapshot.characterAnimationResolution : undefined, characterAnimationRatio: snapshot.characterAnimationRatio === 'same' || snapshot.characterAnimationRatio === '1:1' || snapshot.characterAnimationRatio === '4:3' || snapshot.characterAnimationRatio === '16:9' || snapshot.characterAnimationRatio === '9:16' || snapshot.characterAnimationRatio === '3:4' ? snapshot.characterAnimationRatio : undefined, characterAnimationFrameCount: snapshot.characterAnimationFrameCount === 32 || snapshot.characterAnimationFrameCount === 40 || snapshot.characterAnimationFrameCount === 48 ? snapshot.characterAnimationFrameCount : undefined, characterAnimationDurationSeconds: snapshot.characterAnimationDurationSeconds === 4 || snapshot.characterAnimationDurationSeconds === 5 || snapshot.characterAnimationDurationSeconds === 6 ? snapshot.characterAnimationDurationSeconds : undefined, characterAnimationResult: hydrateCharacterAnimationResult( snapshot.characterAnimationResult, ), soundModel: snapshot.mode === 'audio-sound-effect' ? EDITOR_SOUND_EFFECT_MODEL : undefined, soundDurationMode: snapshot.mode === 'audio-sound-effect' ? snapshot.soundDurationMode === 'auto' ? 'auto' : 'manual' : undefined, soundDurationSeconds: snapshot.mode === 'audio-sound-effect' ? soundDurationOrDefault( snapshot.soundDurationSeconds, snapshot.audioDurationSeconds, ) : undefined, soundLoop: snapshot.mode === 'audio-sound-effect' ? snapshot.soundLoop === true : undefined, makeInstrumental: typeof snapshot.makeInstrumental === 'boolean' ? snapshot.makeInstrumental : undefined, audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds), aspectRatio: stringOrUndefined(snapshot.aspectRatio), imageSize: stringOrUndefined(snapshot.imageSize), errorMessage: hasInvalidPerfectPixelOperation ? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE : // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定 // 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态 // 已经纠正回 idle,面板上却还挂着一句失败提示。 isSettledPerfectPixelPlaceholder ? undefined : stringOrUndefined(snapshot.errorMessage), generationStartedAt: numberOrUndefined(snapshot.generationStartedAt), generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), }; } export function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, options: { onAssetKindOverrideFallback?: ( issue: CanvasAssetKindOverrideFallbackIssue, ) => void; } = {}, ): CanvasLayer | null { const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const resource = resourcesById.get(resourceId); const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; const snapshotImageSequenceFrames = hydrateImageSequenceFrames( snapshot.imageSequenceFrames, ); const resourceImageSequenceFrames = hydrateImageSequenceFrames( resource?.imageSequenceFrames, ); const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind); const hasPersistedAssetKindOverride = Object.prototype.hasOwnProperty.call( snapshot, 'assetKindOverride', ); const persistedAssetKindOverride = hasPersistedAssetKindOverride ? canvasAssetKindOrNull(snapshot.assetKindOverride) : canvasAssetKindOrNull(snapshot.assetKind); const assetKindOverrideFellBack = Boolean( resource && hasPersistedAssetKindOverride && persistedAssetKindOverride && !isCanvasAssetKindOverrideCompatible( resourceAssetKind, persistedAssetKindOverride, ), ); const assetKindOverride = assetKindOverrideFellBack ? null : persistedAssetKindOverride; const assetKind = assetKindOverride ?? resourceAssetKind; const isSelfContainedLocalResource = !resource && isSelfContainedLegacyLocalImageSequence(snapshot); const isFormalCharacterAnimation = assetKind === 'character-animation' && !isSelfContainedLocalResource; if ( isFormalCharacterAnimation && resourceAssetKind !== 'character-animation' ) { return null; } let imageSequenceFrames: NonNullable = []; if (isFormalCharacterAnimation) { imageSequenceFrames = resourceImageSequenceFrames; } else if ( isSelfContainedLocalResource || snapshot.mediaType === 'image-sequence' ) { imageSequenceFrames = snapshotImageSequenceFrames; } const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? ''; const src = isFormalCharacterAnimation ? firstSequenceFrame || resource?.imageSrc || '' : snapshotSrc || firstSequenceFrame || resource?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } if ( isFormalCharacterAnimation && (imageSequenceFrames.length < 2 || !( resource?.imageSequenceDurationMs && resource.imageSequenceDurationMs > 0 )) ) { return null; } if (assetKindOverrideFellBack && persistedAssetKindOverride) { options.onAssetKindOverrideFallback?.({ layerId, resourceId, resourceAssetKind, rejectedAssetKindOverride: persistedAssetKindOverride, }); } let resourcePersistenceState: CanvasLayer['resourcePersistenceState']; if (resource) { resourcePersistenceState = 'registered'; } else if (isSelfContainedLocalResource) { resourcePersistenceState = 'self-contained-local'; } else if (resourceId.startsWith('local-')) { resourcePersistenceState = 'unresolved-local'; } let sourceType: CanvasLayer['sourceType'] = 'uploaded'; if (isCanvasSourceType(resource?.sourceType)) { sourceType = resource.sourceType; } else if (isCanvasSourceType(snapshot.sourceType)) { sourceType = snapshot.sourceType; } const mediaType = resolveHydratedLayerMediaType( snapshot, assetKind, isSelfContainedLocalResource, ); let imageSequenceDurationMs: number | undefined; if (isFormalCharacterAnimation) { imageSequenceDurationMs = audioDurationOrNull(resource?.imageSequenceDurationMs) ?? undefined; } else if (isSelfContainedLocalResource) { imageSequenceDurationMs = audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined; } const resourceGenerationInputs = hydrateCanvasGenerationInputsResult( resource?.generationInputs, ); const snapshotGenerationInputs = hydrateCanvasGenerationInputsResult( snapshot.generationInputs, ); // 资源行是正式配方来源。它若明确是损坏/未来版本的 V2,不能因布局中的旧快照或 // legacy fallback 被掩盖;只有资源值真正 absent 时才读取布局快照。 const generationInputsHydration = isFormalCharacterAnimation ? resourceGenerationInputs : resourceGenerationInputs.state === 'absent' ? snapshotGenerationInputs : resourceGenerationInputs; return { id: layerId, resourceId, resourcePersistenceState, title, src, x: numberFromSnapshot(snapshot.x, 0), y: numberFromSnapshot(snapshot.y, 0), ...(() => { const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320); const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320); return { ...resolveLayerResolutionSize(originalWidth, originalHeight, { width: numberFromSnapshot(snapshot.width, 320), height: numberFromSnapshot(snapshot.height, 320), }), originalWidth, originalHeight, }; })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), sourceType, mediaType, thumbnailSrc: isFormalCharacterAnimation ? firstSequenceFrame || null : stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length ? imageSequenceFrames : undefined, previewVideoPath: isFormalCharacterAnimation ? null : stringOrNull(snapshot.previewVideoPath), prompt: isFormalCharacterAnimation ? stringOrNull(resource?.prompt) : (stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt)), actualPrompt: isFormalCharacterAnimation ? stringOrNull(resource?.actualPrompt) : (stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt)), model: resolveHydratedLayerModel( snapshot, resource, resourcesById, isFormalCharacterAnimation, ), provider: isFormalCharacterAnimation ? stringOrNull(resource?.provider) : (stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider)), taskId: isFormalCharacterAnimation ? stringOrNull(resource?.taskId) : (stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId)), objectKey: isFormalCharacterAnimation ? stringOrNull(resource?.objectKey) : (stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey)), assetObjectId: isFormalCharacterAnimation ? stringOrNull(resource?.assetObjectId) : (stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId)), imageSequenceDurationMs, sourceResourceId: isFormalCharacterAnimation ? stringOrNull(resource?.sourceResourceId) : (stringOrNull(snapshot.sourceResourceId) ?? stringOrNull(resource?.sourceResourceId)), sourceAssetId: isFormalCharacterAnimation ? null : stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), resourceAssetKind, assetKindOverride, assetKind, generationInputs: generationInputsHydration.inputs, generationInputsHydrationState: generationInputsHydration.state, hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), flipY: booleanFromSnapshot(snapshot.flipY), }; } function resolveHydratedLayerMediaType( snapshot: EditorProjectLayerSnapshot, assetKind: CanvasAssetKind | null, isSelfContainedLocalResource: boolean, ): CanvasMediaType { if (isSelfContainedLocalResource) { return 'image-sequence'; } if (assetKind) { return resolveCanvasMediaTypeFromAssetKind(assetKind); } if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') { return snapshot.mediaType; } return 'image'; } function isSelfContainedLegacyLocalImageSequence( snapshot: EditorProjectLayerSnapshot, ) { if ( !snapshot.resourceId.startsWith('local-') || snapshot.sourceType !== 'generated' || snapshot.mediaType !== 'image-sequence' ) { return false; } if ( !isEmptyLegacyLocalSequenceField(snapshot.imageSrc) || !isEmptyLegacyLocalSequenceField(snapshot.objectKey) || !isEmptyLegacyLocalSequenceField(snapshot.assetObjectId) ) { return false; } if ( !Array.isArray(snapshot.imageSequenceFrames) || snapshot.imageSequenceFrames.length === 0 ) { return false; } let firstFrameSrc = ''; for (const [index, value] of snapshot.imageSequenceFrames.entries()) { if (!value || typeof value !== 'object') { return false; } const frame = value as Record; const imageSrc = typeof frame.imageSrc === 'string' ? frame.imageSrc.trim() : ''; if ( frame.frameIndex !== index + 1 || typeof frame.width !== 'number' || !Number.isFinite(frame.width) || frame.width <= 0 || typeof frame.height !== 'number' || !Number.isFinite(frame.height) || frame.height <= 0 || !isStableLegacyCanvasObjectPath(imageSrc) ) { return false; } if (index === 0) { firstFrameSrc = imageSrc; } if (frame.objectKey !== undefined) { if ( typeof frame.objectKey !== 'string' || !isStableLegacyCanvasObjectKey(frame.objectKey) || frame.objectKey.trim() !== imageSrc.replace(/^\/+/, '') ) { return false; } } } const source = typeof snapshot.src === 'string' ? snapshot.src.trim() : ''; if ( source && (!isStableLegacyCanvasObjectPath(source) || source !== firstFrameSrc) ) { return false; } return [snapshot.thumbnailSrc, snapshot.previewVideoPath].every( (value) => value === undefined || value === null || (typeof value === 'string' && isStableLegacyCanvasObjectPath(value)), ); } function isEmptyLegacyLocalSequenceField(value: unknown) { return ( value === undefined || value === null || (typeof value === 'string' && !value.trim()) ); } function isStableLegacyCanvasObjectPath(value: string) { const normalized = value.trim(); return ( normalized.startsWith('/') && !normalized.startsWith('//') && !normalized.includes('?') && !normalized.includes('#') && !normalized.includes('\\') && !normalized.split('/').includes('..') && !hasControlCharacter(normalized) ); } function isStableLegacyCanvasObjectKey(value: string) { const normalized = value.trim(); return ( Boolean(normalized) && !normalized.startsWith('/') && !normalized.includes('?') && !normalized.includes('#') && !normalized.includes('\\') && !normalized.split('/').includes('..') && !hasControlCharacter(normalized) ); } function hasControlCharacter(value: string) { return Array.from(value).some((character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); }); } function resolveHydratedLayerModel( snapshot: EditorProjectLayerSnapshot, resource: CanvasLayerResourceMetadata | undefined, resourcesById: Map, resourceAuthoritative = false, ) { const persistedModel = resourceAuthoritative ? stringOrNull(resource?.model) : (stringOrNull(snapshot.model) ?? stringOrNull(resource?.model)); if (persistedModel && !isEditorInternalProcessingModel(persistedModel)) { return persistedModel; } let sourceResourceId = resourceAuthoritative ? stringOrNull(resource?.sourceResourceId) : (stringOrNull(snapshot.sourceResourceId) ?? stringOrNull(resource?.sourceResourceId)); const visitedResourceIds = new Set(); while (sourceResourceId && !visitedResourceIds.has(sourceResourceId)) { visitedResourceIds.add(sourceResourceId); const sourceResource = resourcesById.get(sourceResourceId); const sourceModel = stringOrNull(sourceResource?.model); if (sourceModel && !isEditorInternalProcessingModel(sourceModel)) { return sourceModel; } sourceResourceId = stringOrNull(sourceResource?.sourceResourceId); } return persistedModel; } export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { folders: EditorAssetFolder[]; assets: EditorAsset[]; } { return { folders: library.folders.map((folder) => ({ id: folder.folderId, label: folder.label, collapsed: folder.collapsed, systemDefault: folder.systemDefault, persisted: true, })), assets: library.assets.map((asset) => { const assetKind = inferEditorAssetKind( asset.label, asset.imageSrc, asset.objectKey, asset.assetKind, ); const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: asset.assetId, label: asset.label, src: asset.imageSrc, mediaType, width: asset.width, height: asset.height, folderId: asset.folderId, sourceKind: 'uploaded', sourceType: asset.sourceType, persisted: true, prompt: asset.prompt ?? undefined, actualPrompt: asset.actualPrompt ?? undefined, model: asset.model ?? undefined, provider: asset.provider ?? undefined, taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, thumbnailSrc: asset.thumbnailSrc ?? undefined, sourceResourceId: asset.sourceResourceId ?? null, publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null, generationCostMudPoints: asset.generationCostMudPoints ?? 0, showcaseId: asset.showcaseId ?? null, showcaseReviewStatus: asset.showcaseReviewStatus ?? null, showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null, showcaseLikeCount: asset.showcaseLikeCount ?? null, assetKind, generationInputs: generationInputsOrNull(asset.generationInputs), generationInputsHydrationState: hydrateCanvasGenerationInputsResult( asset.generationInputs, ).state, imageSequenceFrames: asset.imageSequenceFrames ?? undefined, imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, }; }), }; } export function resolveCanvasMediaTypeFromAssetKind( assetKind?: string | null, ): CanvasMediaType { if (assetKind === 'character-animation') { return 'image-sequence'; } if (assetKind === 'video') { return 'video'; } if ( assetKind === 'audio' || assetKind === 'sound-effect' || assetKind === 'background-music' ) { return 'audio'; } return 'image'; } export function inferEditorAssetMediaType( imageSrc: string, objectKey?: string | null, assetKind?: string | null, ): CanvasMediaType { const normalizedAssetKind = canvasAssetKindOrNull(assetKind); if (normalizedAssetKind) { return resolveCanvasMediaTypeFromAssetKind(normalizedAssetKind); } const sources = [objectKey, imageSrc]; if (sources.some((source) => hasMediaFileExtension(source, '.mp4'))) { return 'video'; } if (sources.some((source) => hasMediaFileExtension(source, '.mp3'))) { return 'audio'; } return 'image'; } function inferEditorAssetKind( label: string, imageSrc: string, objectKey?: string | null, assetKind?: string | null, mediaType?: CanvasMediaType, ): CanvasAssetKind | null { const normalizedAssetKind = canvasAssetKindOrNull(assetKind); if (normalizedAssetKind) { return normalizedAssetKind; } const inferredMediaType = mediaType === 'video' || mediaType === 'audio' ? mediaType : inferEditorAssetMediaType(imageSrc, objectKey); if (inferredMediaType === 'video') { return 'video'; } if (inferredMediaType === 'audio') { return inferAudioAssetKindFromLabel(label); } return null; } function hasMediaFileExtension( source: string | null | undefined, extension: string, ) { const normalizedSource = source?.trim().toLowerCase() ?? ''; const sourceWithoutQuery = normalizedSource.split(/[?#]/u)[0] ?? ''; return sourceWithoutQuery.endsWith(extension); } function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { if (label.includes('背景音乐') || label.toLowerCase().includes('music')) { return 'background-music'; } return 'sound-effect'; } export type CanvasLayerResourceMetadata = { resourceId?: string | null; ownerUserId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; width?: number | null; height?: number | null; sourceType?: string | null; imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; prompt?: string | null; actualPrompt?: string | null; model?: string | null; provider?: string | null; taskId?: string | null; generationInputs?: unknown; imageSequenceFrames?: unknown; createdAt?: string | null; updatedAt?: string | null; }; export type CanvasAssetKindOverrideFallbackIssue = { layerId: string; resourceId: string; resourceAssetKind: CanvasAssetKind | null; rejectedAssetKindOverride: CanvasAssetKind; }; type CanvasAssetMediaFamily = | 'image' | 'character-animation' | 'video' | 'audio'; function resolveCanvasAssetMediaFamily( assetKind: CanvasAssetKind | null | undefined, ): CanvasAssetMediaFamily { if (assetKind === 'character-animation') { return 'character-animation'; } if (assetKind === 'video') { return 'video'; } if ( assetKind === 'audio' || assetKind === 'sound-effect' || assetKind === 'background-music' ) { return 'audio'; } return 'image'; } export function isCanvasAssetKindOverrideCompatible( resourceAssetKind: CanvasAssetKind | null | undefined, assetKindOverride: CanvasAssetKind | null, ) { return ( assetKindOverride === null || resolveCanvasAssetMediaFamily(resourceAssetKind) === resolveCanvasAssetMediaFamily(assetKindOverride) ); } export function resolveLayerResourceAssetKind( layer: CanvasLayer, ): CanvasAssetKind | null { if (layer.resourceAssetKind !== undefined) { return layer.resourceAssetKind; } return layer.assetKindOverride === undefined ? (layer.assetKind ?? null) : null; } export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); let hasDefaultFolder = false; const normalizedFolders = mapped.folders.filter((folder) => { if (!folder.systemDefault) { return true; } if (hasDefaultFolder) { return false; } hasDefaultFolder = true; return true; }); const persistedFolderIds = new Set( normalizedFolders.map((folder) => folder.id), ); const fallbackFolders = hasDefaultFolder ? [] : EDITOR_ASSET_FOLDERS.filter( (folder) => !persistedFolderIds.has(folder.id), ); return { folders: [...normalizedFolders, ...fallbackFolders], assets: mapped.assets, }; } export function numberFromSnapshot(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function numberOrUndefined(value: unknown) { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function audioDurationOrNull(value: unknown) { if (value && typeof value === 'object' && 'durationSeconds' in value) { return audioDurationOrNull( (value as { durationSeconds?: unknown }).durationSeconds, ); } return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } export function bpmOrNull(value: unknown) { if (value === null) { return null; } if (typeof value !== 'number' || !Number.isFinite(value)) { return undefined; } const roundedValue = Math.round(value); return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined; } export function soundDurationOrDefault( value: unknown, fallbackValue?: unknown, ) { const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue); if (!resolvedValue) { return undefined; } return Math.min( SOUND_EFFECT_DURATION_MAX_SECONDS, Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, resolvedValue), ); } export function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } export function stringOrUndefined(value: unknown) { return typeof value === 'string' && value.trim() ? value : undefined; } function videoAspectRatioOrUndefined(value: unknown) { return value === '16:9' || value === '9:16' || value === '1:1' || value === '4:3' || value === '3:4' || value === '21:9' ? value : undefined; } function hydrateCharacterAnimationResult( value: unknown, ): EditorCharacterAnimationGenerationResult | undefined { if (!value || typeof value !== 'object') { return undefined; } const result = value as Partial; if ( typeof result.taskId !== 'string' || result.model !== 'seedance2.0-fast' || typeof result.prompt !== 'string' || typeof result.previewVideoPath !== 'string' || !Array.isArray(result.frames) || typeof result.frameCount !== 'number' || typeof result.durationSeconds !== 'number' || typeof result.fps !== 'number' || typeof result.priceMudPoints !== 'number' ) { return undefined; } return result as EditorCharacterAnimationGenerationResult; } function hydrateImageSequenceFrames( value: unknown, ): NonNullable { if (!Array.isArray(value)) { return []; } const frames = value.flatMap((frame) => { if (!frame || typeof frame !== 'object') { return []; } const snapshot = frame as Record; const imageSrc = stringOrNull(snapshot.imageSrc); if (!imageSrc) { return []; } return [ { imageSrc, objectKey: stringOrUndefined(snapshot.objectKey), assetObjectId: stringOrUndefined(snapshot.assetObjectId), width: numberFromSnapshot(snapshot.width, 0), height: numberFromSnapshot(snapshot.height, 0), }, ]; }); return frames; } export function booleanFromSnapshot(value: unknown) { return value === true; } export function resolveContextMenuPosition( clientX: number, clientY: number, kind: CanvasContextMenuState['kind'], ) { if (typeof window === 'undefined') { return { x: clientX, y: clientY }; } const menuSize = CONTEXT_MENU_SIZE[kind]; return { x: clamp( clientX, CONTEXT_MENU_VIEWPORT_MARGIN, Math.max( CONTEXT_MENU_VIEWPORT_MARGIN, window.innerWidth - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN, ), ), y: clamp( clientY, CONTEXT_MENU_VIEWPORT_MARGIN, Math.max( CONTEXT_MENU_VIEWPORT_MARGIN, window.innerHeight - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN, ), ), }; } export function hasDataTransferType(dataTransfer: DataTransfer, type: string) { return Array.from(dataTransfer.types).includes(type); } export function getDraggedAssetId(dataTransfer: DataTransfer) { if (typeof dataTransfer.getData !== 'function') { return ''; } if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) { return ''; } return dataTransfer.getData(ASSET_DRAG_MIME_TYPE); } export function escapeCssIdentifier(value: string) { return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/gu, '\\$&'); } export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { const assetSourceResourceId = asset.sourceResourceId?.trim(); const assetObjectKey = asset.objectKey?.trim().replace(/^\/+/u, ''); const layerObjectKey = layer.objectKey?.trim().replace(/^\/+/u, ''); const assetSrc = asset.src.trim().replace(/^\/+/u, ''); const layerSrc = layer.src.trim().replace(/^\/+/u, ''); return ( layer.sourceAssetId === asset.id || Boolean( assetSourceResourceId && (layer.resourceId === assetSourceResourceId || layer.sourceResourceId === assetSourceResourceId), ) || Boolean( asset.assetObjectId && layer.assetObjectId === asset.assetObjectId, ) || Boolean(assetObjectKey && layerObjectKey === assetObjectKey) || Boolean(assetSrc && layerSrc === assetSrc) ); } export function generationInputsOrNull( value: unknown, ): CanvasGenerationInputs | null { return hydrateCanvasGenerationInputs(value); } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { return value === 'audio' || value === 'spec' || value === 'character' || value === 'character-animation' || value === 'icon' || value === 'icon-spritesheet' || value === 'icon-spec' || value === 'publication-material' || value === 'ui-design' || value === 'video' || value === 'sound-effect' || value === 'background-music' || value === 'scene' ? value : null; } export function isCanvasSourceType( value: unknown, ): value is CanvasLayer['sourceType'] { return ( value === 'uploaded' || value === 'generated' || value === 'mock_generated' ); } export function isGeneratedLayer(layer: CanvasLayer) { return ( layer.sourceType === 'generated' || layer.sourceType === 'mock_generated' ); } function isCanvasGenerationDialogMode( value: unknown, ): value is CanvasGenerationDialogState['mode'] { return ( value === 'generate' || value === 'spec' || value === 'character' || value === 'icon' || value === 'publication' || value === 'ui-design' || value === 'scene' || value === 'quick-edit' || value === 'character-animation' || value === 'video' || value === 'audio-sound-effect' || value === 'audio-background-music' ); } function isGenerationStatus( value: unknown, ): value is CanvasGenerationDialogState['status'] { return ( value === 'idle' || value === 'generating' || value === 'pending-confirmation' || value === 'failed' ); } function isSpecGenerationType( value: unknown, ): value is NonNullable { return value === 'character' || value === 'icon' || value === 'custom'; } function normalizeSpecGenerationType( value: unknown, ): CanvasGenerationDialogState['specType'] { if (value === 'ui') { return 'icon'; } return isSpecGenerationType(value) ? value : undefined; } function hydratePublicationWorkflowId( value: unknown, ): CanvasGenerationDialogState['publicationWorkflowId'] { return value === 'publication-cover-image' || value === 'publication-detail-gallery' || value === 'publication-promo-poster' ? value : undefined; } function hydratePublicationGameInfo( value: unknown, ): CanvasGenerationDialogState['publicationGameInfo'] { if (!value || typeof value !== 'object') { return undefined; } const snapshot = value as Record; return { gameName: stringOrUndefined(snapshot.gameName) ?? '', gameCategories: stringOrUndefined(snapshot.gameCategories) ?? '', gameDescription: stringOrUndefined(snapshot.gameDescription) ?? '', }; } function hydrateSpecFormValues( value: unknown, ): CanvasGenerationDialogState['specValues'] { if (!value || typeof value !== 'object') { return undefined; } const snapshot = value as Record; return { playSetting: typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '', artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '', bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '', characterView: typeof snapshot.characterView === 'string' ? snapshot.characterView : '', customPrompt: typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '', }; } function resolveHydratedReferenceResource( resourceId: string | undefined, resourcesById: Map, currentUserId?: string | null, ) { if (resourceId) { const resource = resourcesById.get(resourceId); const currentUser = normalizedCurrentUserId(currentUserId); const ownerUserId = resource?.ownerUserId?.trim() || null; if (currentUser && ownerUserId && ownerUserId !== currentUser) { return null; } return resource; } return undefined; } function hydrateCharacterReference( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, ): CharacterReferenceImage | null { if (!value || typeof value !== 'object') { return null; } const snapshot = value as Record; const id = stringOrNull(snapshot.id); const label = stringOrNull(snapshot.label); const src = stringOrNull(snapshot.src); const resourceId = stringOrUndefined(snapshot.resourceId); const sourceAssetId = stringOrUndefined(snapshot.sourceAssetId); const mediaType: CanvasMediaType = snapshot.mediaType === 'video' || snapshot.mediaType === 'audio' ? snapshot.mediaType : 'image'; const mimeType = stringOrUndefined(snapshot.mimeType); const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined; const durationSeconds = numberFromSnapshot(snapshot.durationSeconds, 0) || undefined; const resource = resolveHydratedReferenceResource( resourceId, resourcesById, currentUserId, ); if ( resource === null || (currentUserId && resourceId && !resource) || (!resource && isReferencePointerSrc(src)) ) { return null; } const objectKey = stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey); const assetObjectId = stringOrUndefined(snapshot.assetObjectId) ?? stringOrUndefined(resource?.assetObjectId); const resolvedSrc = resource?.imageSrc || objectKey || src; return id && label && resolvedSrc ? { id, label, src: resolvedSrc, mediaType, mimeType, sizeBytes, durationSeconds, objectKey, assetObjectId, resourceId, sourceAssetId, } : null; } function hydrateCharacterReferences( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, ): CharacterReferenceImage[] | undefined { return Array.isArray(value) ? value.flatMap((reference) => { const hydrated = hydrateCharacterReference( reference, resourcesById, currentUserId, ); return hydrated ? [hydrated] : []; }) : undefined; } function hydrateGenerationPlaceholder( value: unknown, ): CanvasGenerationDialogState['placeholder'] { if (!value || typeof value !== 'object') { return undefined; } const snapshot = value as Record; return { x: numberFromSnapshot(snapshot.x, 0), y: numberFromSnapshot(snapshot.y, 0), width: numberFromSnapshot(snapshot.width, 320), height: numberFromSnapshot(snapshot.height, 320), originalWidth: numberFromSnapshot(snapshot.originalWidth, 320), originalHeight: numberFromSnapshot(snapshot.originalHeight, 320), }; } export function resolveSnappedLayerPosition( movingLayer: CanvasLayer, proposedX: number, proposedY: number, layers: CanvasLayer[], scale: number, ) { return resolveSnappedItemPosition( movingLayer, proposedX, proposedY, layers, scale, ); }