import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel'; import { resolveLayerResolutionSize } from './ImageCanvasEditorModel'; import type { CanvasGenerationInputs, CanvasLayer, CanvasMediaType, CanvasViewport, CharacterReferenceImage, EditorAsset, EditorAssetFolder, GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { appendLimitedImageReferences, QUICK_EDIT_REFERENCE_LIMIT, resolveDialogExtraImageReferenceLimit, resolveExtraImageReferenceLimit, } from './ImageCanvasGenerationModel'; import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; type UploadImageDimensions = { width: number; height: number } | null; type PersistedUploadAsset = { assetId: string; folderId: string; label: string; imageSrc: string; mediaType?: CanvasMediaType; width: number; height: number; objectKey?: string | null; assetObjectId?: string | null; generationInputs?: CanvasGenerationInputs | null; }; type GenerationReferenceUploadTarget = | 'character-reference' | 'character-spec' | 'generation-reference' | 'quick-edit-reference' | 'video-reference-image' | 'video-reference-video' | 'video-reference-audio' | 'icon-spec' | 'spec-reference' | 'ui-design-icon-spec' | 'publication-reference'; const VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, } as const; type VideoReferenceMediaType = keyof typeof VIDEO_REFERENCE_LIMITS; export const UPLOAD_LAYER_FALLBACK_SIZE = { width: 420, height: 315, } as const; export const UPLOAD_VIDEO_FALLBACK_SIZE = { width: 560, height: 315, } as const; export const UPLOAD_AUDIO_FALLBACK_SIZE = { width: 420, height: 120, } as const; export function resolveUploadFallbackSize(mediaType: CanvasMediaType) { if (mediaType === 'video') { return UPLOAD_VIDEO_FALLBACK_SIZE; } if (mediaType === 'audio') { return UPLOAD_AUDIO_FALLBACK_SIZE; } return UPLOAD_LAYER_FALLBACK_SIZE; } export function resolveUploadFolderId({ assetFolders, requestedFolderId, activeUploadFolderId, preferUploadFolder = false, }: { assetFolders: EditorAssetFolder[]; requestedFolderId?: string; activeUploadFolderId: string; preferUploadFolder?: boolean; }) { const targetFolderId = requestedFolderId ?? (preferUploadFolder ? resolvePreferredAssetUploadFolder(assetFolders)?.id : activeUploadFolderId) ?? activeUploadFolderId; return assetFolders.some((folder) => folder.id === targetFolderId) ? targetFolderId : 'project'; } export function createUploadedGenerationReference({ idPrefix, index, fileName, fallbackLabel, imageSrc, mediaType = 'image', mimeType, sizeBytes, durationSeconds, objectKey, assetObjectId, resourceId, sourceAssetId, }: { idPrefix: string; index?: number; fileName?: string; fallbackLabel: string; imageSrc: string; mediaType?: CanvasMediaType; mimeType?: string; sizeBytes?: number; durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; resourceId?: string | null; sourceAssetId?: string | null; }): CharacterReferenceImage { return { id: typeof index === 'number' ? `${idPrefix}-${Date.now()}-${index}` : `${idPrefix}-${Date.now()}`, label: fileName || fallbackLabel, src: imageSrc, mediaType, mimeType, sizeBytes, durationSeconds, objectKey, assetObjectId, resourceId, sourceAssetId, }; } export function setFailedGenerationIdle(dialog: GenerateDialogState) { return { ...dialog, status: dialog.status === 'failed' ? 'idle' : dialog.status, errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, }; } export function applyGenerationReferenceUpload({ dialog, target, references, }: { dialog: GenerateDialogState | null; target: GenerationReferenceUploadTarget; references: CharacterReferenceImage[]; }): GenerateDialogState | null { const firstReference = references[0]; if (!firstReference) { return dialog; } if (target === 'spec-reference') { return dialog?.mode === 'spec' ? { ...setFailedGenerationIdle(dialog), specReference: firstReference, } : dialog; } if (target === 'character-spec') { return dialog?.mode === 'character' ? { ...setFailedGenerationIdle(dialog), characterSpecReference: firstReference, characterReferences: dialog.characterReferences, } : dialog; } if (target === 'icon-spec') { return dialog?.mode === 'icon' ? { ...setFailedGenerationIdle(dialog), iconSpecReference: firstReference, generationReferences: appendLimitedImageReferences( [], dialog.generationReferences ?? [], resolveDialogExtraImageReferenceLimit(dialog), ), } : dialog; } if (target === 'ui-design-icon-spec') { return dialog?.mode === 'ui-design' ? { ...setFailedGenerationIdle(dialog), uiDesignSpecReference: firstReference, generationReferences: dialog.generationReferences, } : dialog; } if (target === 'publication-reference') { return dialog?.mode === 'publication' ? { ...setFailedGenerationIdle(dialog), publicationReferences: appendLimitedImageReferences( dialog.publicationReferences, references, resolveDialogExtraImageReferenceLimit(dialog), ), composerOpen: true, } : dialog; } if (target === 'generation-reference') { return dialog?.mode === 'generate' || dialog?.mode === 'scene' || dialog?.mode === 'video' || dialog?.mode === 'spec' || dialog?.mode === 'icon' || dialog?.mode === 'ui-design' ? { ...setFailedGenerationIdle(dialog), generationReferences: dialog.mode === 'video' ? appendLimitedVideoReferences( dialog.generationReferences ?? [], references, 'image', ) : appendLimitedImageReferences( dialog.generationReferences, references, resolveDialogExtraImageReferenceLimit(dialog), ), } : dialog; } if ( target === 'video-reference-image' || target === 'video-reference-video' || target === 'video-reference-audio' ) { const mediaType: VideoReferenceMediaType = target === 'video-reference-video' ? 'video' : target === 'video-reference-audio' ? 'audio' : 'image'; return dialog?.mode === 'video' ? { ...setFailedGenerationIdle(dialog), generationReferences: appendLimitedVideoReferences( dialog.generationReferences ?? [], references, mediaType, ), composerOpen: true, } : dialog; } return dialog?.mode === 'character' ? { ...setFailedGenerationIdle(dialog), characterReferences: appendLimitedImageReferences( dialog.characterReferences, references, resolveDialogExtraImageReferenceLimit(dialog), ), } : dialog; } export function applyQuickEditReferenceUpload({ panel, references, }: { panel: QuickEditPanelState | null; references: CharacterReferenceImage[]; }): QuickEditPanelState | null { if (!panel) { return panel; } return { ...panel, status: panel.status === 'failed' ? 'idle' : panel.status, errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, quickEditReferences: appendLimitedImageReferences( panel.quickEditReferences, references, resolveExtraImageReferenceLimit( panel.model, QUICK_EDIT_REFERENCE_LIMIT, 1, ), ), }; } function appendLimitedVideoReferences( currentReferences: CharacterReferenceImage[], nextReferences: CharacterReferenceImage[], mediaType: VideoReferenceMediaType, ) { const limit = VIDEO_REFERENCE_LIMITS[mediaType]; const currentCount = currentReferences.filter( (reference) => (reference.mediaType ?? 'image') === mediaType, ).length; const remaining = Math.max(0, limit - currentCount); return [ ...currentReferences, ...nextReferences.slice(0, remaining).map((reference) => ({ ...reference, mediaType, })), ]; } export function createUploadingAssetPlaceholder({ uploadIndex, fileName, folderId, mediaType = 'image', imageDimensions, }: { uploadIndex: number; fileName?: string; folderId: string; mediaType?: CanvasMediaType; imageDimensions?: UploadImageDimensions; }): EditorAsset { const fallbackSize = resolveUploadFallbackSize(mediaType); const size = mediaType !== 'audio' && imageDimensions ? resolveLayerResolutionSize( imageDimensions.width, imageDimensions.height, fallbackSize, ) : fallbackSize; return { id: `upload-${uploadIndex}`, label: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'), src: '', mediaType, width: size.width, height: size.height, folderId, sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, assetKind: resolveUploadedAssetKind(mediaType), uploadStatus: 'uploading', uploadProgress: 8, uploadMessage: '准备上传', }; } export function expandUploadFolder({ folders, folderId, }: { folders: EditorAssetFolder[]; folderId: string; }) { return folders.map((folder) => folder.id === folderId ? { ...folder, collapsed: false, } : folder, ); } export function applyUploadAssetReadSuccess({ assets, uploadAssetId, imageSrc, }: { assets: EditorAsset[]; uploadAssetId: string; imageSrc: string; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, src: imageSrc, uploadProgress: 42, uploadMessage: '读取素材', } : asset, ); } export function applyUploadAssetReadFailure({ assets, uploadAssetId, }: { assets: EditorAsset[]; uploadAssetId: string; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, uploadStatus: 'failed' as const, uploadProgress: 100, uploadMessage: '读取失败', } : asset, ); } export function applyUploadAssetCreatePending({ assets, uploadAssetId, }: { assets: EditorAsset[]; uploadAssetId: string; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, uploadProgress: 68, uploadMessage: '上传中', } : asset, ); } export function applyPersistedUploadAsset({ assets, uploadAssetId, persistedAsset, }: { assets: EditorAsset[]; uploadAssetId: string; persistedAsset: PersistedUploadAsset; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, id: persistedAsset.assetId, folderId: persistedAsset.folderId, label: persistedAsset.label, src: persistedAsset.imageSrc, mediaType: persistedAsset.mediaType ?? asset.mediaType, width: persistedAsset.width, height: persistedAsset.height, objectKey: persistedAsset.objectKey ?? undefined, assetObjectId: persistedAsset.assetObjectId ?? undefined, generationInputs: persistedAsset.generationInputs ?? asset.generationInputs, persisted: true, uploadStatus: undefined, uploadProgress: undefined, uploadMessage: undefined, } : asset, ); } export function applyUploadAssetCreateFailure({ assets, uploadAssetId, message, }: { assets: EditorAsset[]; uploadAssetId: string; message: string; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, uploadStatus: 'failed' as const, uploadProgress: 100, uploadMessage: message, } : asset, ); } export function applyMeasuredUploadAssetSize({ assets, uploadAssetId, originalWidth, originalHeight, }: { assets: EditorAsset[]; uploadAssetId: string; originalWidth: number; originalHeight: number; }) { return assets.map((asset) => asset.id === uploadAssetId ? { ...asset, width: originalWidth, height: originalHeight, } : asset, ); } export function bindUploadLayerToPersistedAsset({ layers, layerId, persistedAsset, }: { layers: CanvasLayer[]; layerId: string; persistedAsset: PersistedUploadAsset; }) { return layers.map((layer) => layer.id === layerId ? { ...layer, src: persistedAsset.imageSrc || layer.src, sourceAssetId: persistedAsset.assetId, mediaType: persistedAsset.mediaType ?? layer.mediaType, objectKey: persistedAsset.objectKey ?? layer.objectKey, assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId, } : layer, ); } export function normalizeUploadScreenPoint({ canvasPoint, canvasSize, }: { canvasPoint?: CanvasPoint; canvasSize: CanvasSize; }): CanvasPoint { const fallbackScreenPoint = { x: canvasSize.width > 0 ? canvasSize.width / 2 : 640, y: canvasSize.height > 0 ? canvasSize.height / 2 : 360, }; const screenPoint = canvasPoint ?? fallbackScreenPoint; return { x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x, y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y, }; } export function buildUploadedMediaGenerationInputs( mediaType: CanvasMediaType, durationSeconds: number | undefined, ): CanvasGenerationInputs | undefined { if ( mediaType === 'image' || typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0 ) { return undefined; } const roundedDurationSeconds = Math.round(durationSeconds * 1_000) / 1_000; return { fields: [{ title: '时长', value: `${roundedDurationSeconds}秒` }], references: [], }; } export function createUploadCanvasLayer({ uploadIndex, fileName, imageSrc, objectKey, assetObjectId, mediaType = 'image', imageDimensions, durationSeconds, canvasPoint, canvasSize, viewport, }: { uploadIndex: number; fileName?: string; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; mediaType?: CanvasMediaType; imageDimensions?: UploadImageDimensions; durationSeconds?: number; canvasPoint?: CanvasPoint; canvasSize: CanvasSize; viewport: CanvasViewport; }): CanvasLayer { const fallbackSize = resolveUploadFallbackSize(mediaType); const layerSize = mediaType !== 'audio' && imageDimensions ? resolveLayerResolutionSize( imageDimensions.width, imageDimensions.height, fallbackSize, ) : fallbackSize; const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize }); const safeScale = viewport.scale > 0 ? viewport.scale : 1; const worldCenterX = (screenPoint.x - viewport.x) / safeScale; const worldCenterY = (screenPoint.y - viewport.y) / safeScale; return { id: `layer-upload-${uploadIndex}`, resourceId: `local-resource-upload-${uploadIndex}`, title: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'), src: imageSrc, mediaType, assetKind: resolveUploadedAssetKind(mediaType), x: worldCenterX - layerSize.width / 2, y: worldCenterY - layerSize.height / 2, width: layerSize.width, height: layerSize.height, originalWidth: layerSize.width, originalHeight: layerSize.height, zIndex: uploadIndex + 10, sourceType: 'uploaded', sourceAssetId: `upload-${uploadIndex}`, objectKey: objectKey ?? undefined, assetObjectId: assetObjectId ?? undefined, generationInputs: buildUploadedMediaGenerationInputs( mediaType, durationSeconds, ), }; } export function resizeUploadCanvasLayerToImage({ layer, originalWidth, originalHeight, canvasPoint, canvasSize, viewport, }: { layer: CanvasLayer; originalWidth: number; originalHeight: number; canvasPoint?: CanvasPoint; canvasSize: CanvasSize; viewport: CanvasViewport; }): CanvasLayer { const safeOriginalWidth = originalWidth || UPLOAD_LAYER_FALLBACK_SIZE.width; const safeOriginalHeight = originalHeight || UPLOAD_LAYER_FALLBACK_SIZE.height; const { width, height } = resolveLayerResolutionSize( safeOriginalWidth, safeOriginalHeight, UPLOAD_LAYER_FALLBACK_SIZE, ); const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize }); const safeScale = viewport.scale > 0 ? viewport.scale : 1; const worldCenterX = (screenPoint.x - viewport.x) / safeScale; const worldCenterY = (screenPoint.y - viewport.y) / safeScale; return { ...layer, width, height, originalWidth: safeOriginalWidth, originalHeight: safeOriginalHeight, x: worldCenterX - width / 2, y: worldCenterY - height / 2, }; }