import type { EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, EditorProjectLayerSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, CanvasContextMenuState, CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, CanvasSnapItem, CanvasMediaType, CanvasViewport, CharacterReferenceImage, EditorAsset, EditorAssetFolder, SnapCandidate, } from './ImageCanvasEditorTypes'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: false, }, ]; export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1; const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; export const MINIMAP_DRAG_SENSITIVITY = 0.3; export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; export const MAX_HISTORY_STEPS = 60; export const CONTEXT_MENU_VIEWPORT_MARGIN = 8; export const CONTEXT_MENU_SIZE = { blank: { width: 188, height: 176 }, layer: { width: 188, height: 492 }, } 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 clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } export function formatCanvasDisplayScalePercent(scale: number) { const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE); } export function canvasDisplayScaleToViewportScale(displayScale: number) { const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1; return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE; } export function viewportScaleToCanvasDisplayScale(scale: number) { const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; return safeScale / CANVAS_DISPLAY_SCALE_BASE; } 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 }, ): CanvasLayer { 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 = index * 34; const assetKind: CanvasAssetKind | undefined = asset.mediaType === 'video' ? 'video' : asset.mediaType === 'audio' ? inferAudioAssetKindFromLabel(asset.label) : undefined; return { id: `layer-${asset.id}-${index}`, resourceId: `local-resource-${asset.id}-${index}`, title: asset.label, src: asset.src, mediaType: asset.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, durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, assetKind: asset.assetKind ?? assetKind, generationInputs: asset.generationInputs, } satisfies CanvasLayer; } 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; } export function serializeLayer( layer: CanvasLayer, ): EditorProjectLayerSnapshot { 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, 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, model: layer.model, provider: layer.provider, taskId: layer.taskId, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, durationSeconds: layer.durationSeconds, sourceResourceId: layer.sourceResourceId, sourceAssetId: layer.sourceAssetId, groupId: layer.groupId, hidden: layer.hidden, locked: layer.locked, flipX: layer.flipX, flipY: layer.flipY, }; } type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & { itemType: 'generation-dialog'; dialog: CanvasGenerationDialogState; }; export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; 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 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] : []; }); } function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { return { ...dialog, 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), }; } export function serializeCanvasLayout({ layers, canvasGenerationDialogs, }: { layers: CanvasLayer[]; canvasGenerationDialogs: CanvasGenerationDialogState[]; }): CanvasLayoutItems { return [ ...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, ), ) ); } export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; } { const layerItems: EditorProjectLayerSnapshot[] = []; const generationDialogs: CanvasGenerationDialogState[] = []; items.forEach((item) => { if (isCanvasGenerationDialogLayoutItem(item)) { const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById); if (dialog) { generationDialogs.push(dialog); } return; } layerItems.push(item); }); return { layerItems, generationDialogs }; } export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), ): 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; } return { id, mode: snapshot.mode, prompt, status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', composerOpen: typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true, sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), generatedLayerId: stringOrUndefined(snapshot.generatedLayerId), specType: isSpecGenerationType(snapshot.specType) ? snapshot.specType : undefined, specValues: hydrateSpecFormValues(snapshot.specValues), specReference: hydrateCharacterReference( snapshot.specReference, resourcesById, ), generationReferences: hydrateCharacterReferences( snapshot.generationReferences, resourcesById, ), characterSpecReference: hydrateCharacterReference( snapshot.characterSpecReference, resourcesById, ), characterReferences: hydrateCharacterReferences( snapshot.characterReferences, resourcesById, ), iconSpecReference: hydrateCharacterReference( snapshot.iconSpecReference, resourcesById, ), 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, ), uiDesignSpecReference: hydrateCharacterReference( snapshot.uiDesignSpecReference, resourcesById, ), imageModel: stringOrUndefined(snapshot.imageModel), 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.soundModel === 'audio1.0' ? 'audio1.0' : undefined, soundDurationSeconds: soundDurationOrDefault( snapshot.soundDurationSeconds, snapshot.audioDurationSeconds, ), makeInstrumental: typeof snapshot.makeInstrumental === 'boolean' ? snapshot.makeInstrumental : undefined, audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds), aspectRatio: stringOrUndefined(snapshot.aspectRatio), imageSize: stringOrUndefined(snapshot.imageSize), errorMessage: stringOrUndefined(snapshot.errorMessage), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), }; } export function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, ): 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 imageSequenceFrames = hydrateImageSequenceFrames( snapshot.imageSequenceFrames, ); const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? ''; const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } return { id: layerId, resourceId, 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: isCanvasSourceType(snapshot.sourceType) ? snapshot.sourceType : 'uploaded', mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames), thumbnailSrc: stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length ? imageSequenceFrames : undefined, previewVideoPath: stringOrNull(snapshot.previewVideoPath), prompt: stringOrNull(snapshot.prompt), actualPrompt: stringOrNull(snapshot.actualPrompt), model: stringOrNull(snapshot.model), provider: stringOrNull(snapshot.provider), taskId: stringOrNull(snapshot.taskId), objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), assetObjectId: stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId), durationSeconds: audioDurationOrNull(snapshot.durationSeconds) ?? audioDurationOrNull(resource?.durationSeconds), sourceResourceId: stringOrNull(snapshot.sourceResourceId) ?? stringOrNull(resource?.sourceResourceId), sourceAssetId: stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(resource?.assetKind) ?? canvasAssetKindOrNull(snapshot.assetKind), generationInputs: generationInputsOrNull(resource?.generationInputs) ?? generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), flipY: booleanFromSnapshot(snapshot.flipY), }; } 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 mediaType = inferEditorAssetMediaType( asset.imageSrc, asset.objectKey ?? undefined, asset.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, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), durationSeconds: asset.durationSeconds ?? undefined, }; }), }; } export function inferEditorAssetMediaType( imageSrc: string, objectKey?: string | null, assetKind?: string | null, ): CanvasMediaType { if (assetKind === 'video') { return 'video'; } if (assetKind === 'sound-effect' || assetKind === 'background-music') { return 'audio'; } const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase(); const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source; if (sourceWithoutQuery.includes('.mp4')) { return 'video'; } if (sourceWithoutQuery.includes('.mp3')) { return 'audio'; } return 'image'; } function resolveHydratedLayerMediaType( snapshot: EditorProjectLayerSnapshot, imageSequenceFrames: CanvasLayer['imageSequenceFrames'], ): CanvasMediaType { if (snapshot.mediaType === 'image-sequence') { return 'image-sequence'; } if ( snapshot.assetKind === 'character-animation' && (imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc)) ) { return 'image-sequence'; } if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') { return snapshot.mediaType; } return 'image'; } function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { return label.includes('背景音乐') || label.toLowerCase().includes('music') ? 'background-music' : 'sound-effect'; } export type CanvasLayerResourceMetadata = { resourceId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; width?: number | null; height?: number | null; sourceType?: string | null; durationSeconds?: 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; createdAt?: string | null; updatedAt?: string | 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 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(10, Math.max(2, Math.round(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, index) => { if (!frame || typeof frame !== 'object') { return []; } const snapshot = frame as Record; const imageSrc = stringOrNull(snapshot.imageSrc); if (!imageSrc) { return []; } return [ { frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1), 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 { if (!value || typeof value !== 'object') { return null; } const snapshot = value as { fields?: unknown; references?: unknown; }; const fields = Array.isArray(snapshot.fields) ? snapshot.fields.flatMap((field) => { if (!field || typeof field !== 'object') { return []; } const item = field as { title?: unknown; value?: unknown }; const title = stringOrNull(item.title); const fieldValue = stringOrNull(item.value); return title && fieldValue ? [{ title, value: fieldValue }] : []; }) : []; const references = Array.isArray(snapshot.references) ? snapshot.references.flatMap((reference) => { if (!reference || typeof reference !== 'object') { return []; } const item = reference as { title?: unknown; label?: unknown; refType?: unknown; refId?: unknown; }; const title = stringOrNull(item.title); const label = stringOrNull(item.label); const refType: 'project-resource' | 'asset' | null = item.refType === 'project-resource' || item.refType === 'asset' ? item.refType : null; const refId = stringOrNull(item.refId); return title && label && refType && refId ? [{ title, label, refType, refId }] : []; }) : []; return fields.length || references.length ? { fields, references } : null; } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { return 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 : 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 === '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 === 'failed'; } function isSpecGenerationType( value: unknown, ): value is NonNullable { return ( value === 'character' || value === 'ui' || value === 'icon' || value === 'custom' ); } 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, ) { if (resourceId) { return resourcesById.get(resourceId); } return undefined; } function hydrateCharacterReference( value: unknown, resourcesById: Map = new Map(), ): 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); 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(), ): CharacterReferenceImage[] | undefined { return Array.isArray(value) ? value.flatMap((reference) => { const hydrated = hydrateCharacterReference(reference, resourcesById); 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 getLayerBounds(targetLayers: CanvasLayer[]) { if (targetLayers.length === 0) { return null; } return targetLayers.reduce( (current, layer) => ({ minX: Math.min(current.minX, layer.x), minY: Math.min(current.minY, layer.y), maxX: Math.max(current.maxX, layer.x + layer.width), maxY: Math.max(current.maxY, layer.y + layer.height), }), { minX: Number.POSITIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY, }, ); } export function resolveSnappedLayerPosition( movingLayer: CanvasLayer, proposedX: number, proposedY: number, layers: CanvasLayer[], scale: number, ) { return resolveSnappedItemPosition( movingLayer, proposedX, proposedY, layers, scale, ); } export function resolveSnappedItemPosition( movingItem: CanvasSnapItem, proposedX: number, proposedY: number, items: CanvasSnapItem[], scale: number, ) { const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE); const visibleItems = items.filter( (item) => item.id !== movingItem.id && !item.hidden, ); const verticalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...visibleItems.flatMap((item) => [ item.x, item.x + item.width / 2, item.x + item.width, ]), ]; const horizontalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...visibleItems.flatMap((item) => [ item.y, item.y + item.height / 2, item.y + item.height, ]), ]; const xAlignmentSnap = findNearestSnap( proposedX, [0, movingItem.width / 2, movingItem.width], verticalTargets, threshold, ); const yAlignmentSnap = findNearestSnap( proposedY, [0, movingItem.height / 2, movingItem.height], horizontalTargets, threshold, ); const xDistributionSnap = findNearestEqualSpacingSnap({ axis: 'x', proposedStart: proposedX, proposedCrossStart: proposedY, movingSize: movingItem.width, movingCrossSize: movingItem.height, items: visibleItems, threshold, }); const yDistributionSnap = findNearestEqualSpacingSnap({ axis: 'y', proposedStart: proposedY, proposedCrossStart: proposedX, movingSize: movingItem.height, movingCrossSize: movingItem.width, items: visibleItems, threshold, }); const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap); const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap); return { x: xSnap ? xSnap.position : proposedX, y: ySnap ? ySnap.position : proposedY, guide: xSnap || ySnap ? { vertical: xSnap?.guide, horizontal: ySnap?.guide, } : null, }; } function chooseNearestSnap( first: SnapCandidate | null, second: SnapCandidate | null, ) { if (!first) { return second; } if (!second) { return first; } return second.distance < first.distance ? second : first; } function findNearestEqualSpacingSnap({ axis, proposedStart, proposedCrossStart, movingSize, movingCrossSize, items, threshold, }: { axis: 'x' | 'y'; proposedStart: number; proposedCrossStart: number; movingSize: number; movingCrossSize: number; items: CanvasSnapItem[]; threshold: number; }): SnapCandidate | null { const orderedItems = items .filter((item) => snapItemsOverlapOnCrossAxis( proposedCrossStart, movingCrossSize, item, axis, ), ) .sort( (firstItem, secondItem) => getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), ); let nearest: SnapCandidate | null = null; for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) { const firstItem = orderedItems[firstIndex]; if (!firstItem) { continue; } for ( let secondIndex = firstIndex + 1; secondIndex < orderedItems.length && secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD; secondIndex += 1 ) { const secondItem = orderedItems[secondIndex]; if (!secondItem) { continue; } const firstStart = getSnapItemStart(firstItem, axis); const firstEnd = getSnapItemEnd(firstItem, axis); const secondStart = getSnapItemStart(secondItem, axis); const secondEnd = getSnapItemEnd(secondItem, axis); const pairGap = secondStart - firstEnd; if (pairGap < 0) { continue; } nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: firstStart - pairGap - movingSize, proposedStart, movingSize, threshold, }), ); nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: secondEnd + pairGap, proposedStart, movingSize, threshold, }), ); const betweenGap = (pairGap - movingSize) / 2; if (betweenGap >= 0) { nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: firstEnd + betweenGap, proposedStart, movingSize, threshold, }), ); } } } return nearest; } function createDistributionSnapCandidate({ position, proposedStart, movingSize, threshold, }: { position: number; proposedStart: number; movingSize: number; threshold: number; }): SnapCandidate | null { const distance = Math.abs(position - proposedStart); if (distance > threshold) { return null; } return { position, guide: position + movingSize / 2, distance, }; } function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.x : item.y; } function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.width : item.height; } function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') { return getSnapItemStart(item, axis) + getSnapItemSize(item, axis); } function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.y : item.x; } function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.height : item.width; } function snapItemsOverlapOnCrossAxis( proposedCrossStart: number, movingCrossSize: number, item: CanvasSnapItem, axis: 'x' | 'y', ) { const movingCrossEnd = proposedCrossStart + movingCrossSize; const itemCrossStart = getSnapItemCrossStart(item, axis); const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis); return ( proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE ); } export function findNearestSnap( origin: number, offsets: number[], targets: number[], threshold: number, ): SnapCandidate | null { let nearest: SnapCandidate | null = null; for (const offset of offsets) { for (const target of targets) { const distance = Math.abs(target - (origin + offset)); if (distance > threshold) { continue; } if (!nearest || distance < nearest.distance) { nearest = { position: target - offset, guide: target, distance, }; } } } return nearest; }