import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useLayoutEffect, useMemo, useRef, } from 'react'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { getExternalGenerationJobStatus } from '../../services/external-generation'; import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { type EditorMediaAssetUploadType, uploadEditorMediaAssetObjectFile, } from '../../services/image-editor/editorMediaAssetUploadClient'; import type { EditorAssetSnapshot, EditorCanvasGenerationCompletionInput, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { editEditorImage, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, generateEditorIconSpec, generateEditorIconSpritesheet, generateEditorImage, generateEditorScene, generateEditorSoundEffect, generateEditorVideo, loadEditorProject, } from '../../services/image-editor/editorProjectClient'; import { findCanvasGenerationDialogRecords, isUnresolvedCanvasGenerationDialogRecord, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputs, CanvasHistoryAction, CanvasLayer, CanvasTool, CanvasViewport, CharacterAnimationPanelState, CharacterReferenceImage, GenerateDialogState, QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { resolveSpecGenerationReferences } from './ImageCanvasGenerationDialogModel'; import { applyImageEditResultToSourceLayer, createAudioResultLayer, createCharacterAnimationResultLayer, createGeneratedResultLayer, createIconSpritesheetResultLayers, createVideoResultLayer, } from './ImageCanvasGenerationLayerModel'; import { buildCharacterAnimationGenerationInputs, buildQuickEditGenerationInputs, buildUiDesignAssetExtractionGenerationInputs, DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_IMAGE_MODEL, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isCanvasGenerationDialog, isQuickEditSupportedLayer, normalizeEditorImageModel, resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, resolveGenerationAssetLabel, resolveImageGenerationDialogPrompt, resolveRegisteredEditorReferenceId, } from './ImageCanvasGenerationSubmissionModel'; import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; import type { UiAssetExtractionMark, UiAssetExtractionState, } from './ImageCanvasUiAssetExtractionModel'; import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel'; import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel'; import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist'; import type { SoundEffectPromptAssistController } from './useImageCanvasSoundEffectPromptAssist'; type CanvasSize = { width: number; height: number }; const EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX = '图集已生成,但自动拆分未完成:'; type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; type UiDesignAssetExtractionOptions = { marks?: UiAssetExtractionMark[]; model?: string; references?: CharacterReferenceImage[]; suppressAlert?: boolean; }; type EditorGenerationMediaReference = { src: string; objectKey?: string | null; resourceId?: string | null; sourceAssetId?: string | null; }; type EditorGenerationMediaReferenceOptions = { allowRegisteredIds?: boolean; requireImageObjectReference?: boolean; // 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次 // 上传生成随机路径,避免并发参考图互相覆盖。 uploadId?: string; // 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及 // ticket → PUT → confirm,不能只在最外层停止 await。 signal?: AbortSignal; }; let editorGenerationUploadFallbackCounter = 0; export function createEditorGenerationMediaUploadId() { const cryptoApi = globalThis.crypto; if (typeof cryptoApi?.randomUUID === 'function') { return cryptoApi.randomUUID(); } if (typeof cryptoApi?.getRandomValues === 'function') { const words = cryptoApi.getRandomValues(new Uint32Array(4)); return Array.from(words, (word) => word.toString(16).padStart(8, '0')).join( '', ); } editorGenerationUploadFallbackCounter += 1; return `${Date.now().toString(36)}-${editorGenerationUploadFallbackCounter.toString(36)}-${Math.random().toString(36).slice(2)}`; } function resolveEditorGenerationMediaReferenceSource( reference: EditorGenerationMediaReference, allowRegisteredIds: boolean, ) { const resourceId = reference.resourceId?.trim(); return ( reference.objectKey?.trim() || (allowRegisteredIds && resourceId && !resourceId.startsWith('local-resource-') && !resourceId.startsWith('generation-dialog:') ? resourceId : '') || (allowRegisteredIds ? reference.sourceAssetId?.trim() : '') || reference.src.trim() ); } function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) { signal?.throwIfAborted(); } function dataUrlToEditorGenerationFile( dataUrl: string, fileName: string, signal?: AbortSignal, ) { throwIfEditorGenerationMediaUploadAborted(signal); const [header = '', payload = ''] = dataUrl.split(','); const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header); if (!mimeMatch) { throw new Error('生成参考图不是有效的 Data URL'); } const type = mimeMatch[1] ?? 'image/png'; const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload); throwIfEditorGenerationMediaUploadAborted(signal); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } throwIfEditorGenerationMediaUploadAborted(signal); return new File([bytes], fileName, { type }); } async function inlineMediaSourceToEditorGenerationFile( source: string, mediaType: EditorMediaAssetUploadType, uploadId: string, signal?: AbortSignal, ) { throwIfEditorGenerationMediaUploadAborted(signal); const fileName = `generation-reference-${uploadId}.${ mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png' }`; if (/^data:/iu.test(source)) { return dataUrlToEditorGenerationFile(source, fileName, signal); } const response = await fetch(source, { signal }); throwIfEditorGenerationMediaUploadAborted(signal); if (!response.ok) { throw new Error('读取本地生成参考素材失败'); } const blob = await response.blob(); throwIfEditorGenerationMediaUploadAborted(signal); return new File([blob], fileName, { type: blob.type || `${mediaType}/*`, }); } async function uploadEditorGenerationInlineMediaSource( source: string, mediaType: EditorMediaAssetUploadType, projectId?: string | null, signal?: AbortSignal, stableUploadId?: string | null, ) { const normalizedProjectId = projectId?.trim() || 'unscoped'; const uploadId = stableUploadId?.trim() || createEditorGenerationMediaUploadId(); const uploaded = await uploadEditorMediaAssetObjectFile( await inlineMediaSourceToEditorGenerationFile( source, mediaType, uploadId, signal, ), mediaType, { assetKind: `editor_generation_reference_${mediaType}`, pathSegments: [ 'editor', 'generation-references', normalizedProjectId, uploadId, ], entityId: normalizedProjectId, signal, ...(projectId?.trim() ? { metadata: { editor_project_id: projectId.trim() } } : {}), }, ); return uploaded.objectKey; } export async function resolveEditorGenerationMediaReference( reference: EditorGenerationMediaReference, mediaType: EditorMediaAssetUploadType, projectId?: string | null, options: EditorGenerationMediaReferenceOptions = {}, ) { throwIfEditorGenerationMediaUploadAborted(options.signal); const resourceId = reference.resourceId?.trim(); const hasRegisteredReference = options.allowRegisteredIds !== false && ((resourceId && !resourceId.startsWith('local-resource-') && !resourceId.startsWith('generation-dialog:')) || reference.sourceAssetId?.trim()); const source = resolveEditorGenerationMediaReferenceSource( reference, options.allowRegisteredIds !== false, ); const inlineSource = /^(?:data|blob):/iu.test(source); const imageSourceRequiresUpload = options.requireImageObjectReference !== false && mediaType === 'image' && !reference.objectKey?.trim() && !hasRegisteredReference; if (!inlineSource && !imageSourceRequiresUpload) { return source; } const uploadSource = imageSourceRequiresUpload && !inlineSource ? await resolveEditorImageReferenceDataUrl(source, options.signal) : source; throwIfEditorGenerationMediaUploadAborted(options.signal); return uploadEditorGenerationInlineMediaSource( uploadSource, mediaType, projectId, options.signal, options.uploadId, ); } async function uploadEditorGenerationImageDataUrl( dataUrl: string, projectId?: string | null, ) { return uploadEditorGenerationInlineMediaSource(dataUrl, 'image', projectId); } const EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS = 1600; const EDITOR_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000; const EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS = 650; function getCanvasCompletionPlaceholderSizeFromPlan({ sourceLayer, outputSize, }: { sourceLayer: CanvasLayer; outputSize: string | null | undefined; }) { const match = /^(\d+)x(\d+)$/i.exec(outputSize?.trim() ?? ''); if (!match) { return { width: sourceLayer.originalWidth, height: sourceLayer.originalHeight, }; } const width = Number(match[1]); const height = Number(match[2]); return { width: Number.isFinite(width) && width > 0 ? width : sourceLayer.originalWidth, height: Number.isFinite(height) && height > 0 ? height : sourceLayer.originalHeight, }; } type GenerationSubmissionWorkflowOptions = { layers: CanvasLayer[]; canvasSize: CanvasSize; viewport: CanvasViewport; layerCounterRef: MutableRefObject; quickEditPanel: QuickEditPanelState | null; quickEditSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; setQuickEditPanel: Dispatch>; characterAnimationPanel: CharacterAnimationPanelState | null; characterAnimationDialog: CanvasGenerationDialogState | null; characterAnimationSourceLayer: CanvasLayer | null; setCharacterAnimationPanel: Dispatch< SetStateAction >; setGenerateDialog: Dispatch>; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; hasCanvasGenerationDialogById: (dialogId: string) => boolean; getCanvasGenerationDialogById: ( dialogId: string, ) => CanvasGenerationDialogState | undefined; activeCanvasGenerationDialogId?: string | null; backgroundMusicPromptAssist: BackgroundMusicPromptAssistController; soundEffectPromptAssist: SoundEffectPromptAssistController; getGeneratingDialogPlaceholder: ( dialog: GenerateDialogState, ) => GenerateDialogState['placeholder']; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; captureCanvasHistory: (action: CanvasHistoryAction) => void; updateSourceLayer: ( sourceLayerId: string, updater: (layer: CanvasLayer) => CanvasLayer, options?: { fit?: boolean; persist?: boolean }, ) => void; selectSingleLayer: (layerId: string | null) => void; fitLayers: ( targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }, ) => void; setActiveTool: Dispatch>; setActiveSidebarPanel: Dispatch>; rememberImageModel: (imageModel: string) => void; projectId?: string | null; currentUserId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; onQueuedGenerationTask?: () => void; onWalletBalanceMayHaveChanged?: () => void; onGenerationWarning?: (message: string) => void; }; async function normalizeGenerationReferenceImages< T extends { referenceImageSrcs?: string[] }, >( input: T, references: CharacterReferenceImage[], projectId?: string | null, ): Promise { if (!input.referenceImageSrcs?.length) { return input; } return { ...input, referenceImageSrcs: await Promise.all( input.referenceImageSrcs.map((referenceImageSrc, index) => resolveEditorGenerationMediaReference( references[index] ? { ...references[index], src: referenceImageSrc } : { src: referenceImageSrc }, 'image', projectId, ), ), ), }; } function resolveImageGenerationDialogReferences( dialog: GenerateDialogState, ): CharacterReferenceImage[] { if (dialog.mode === 'spec') { return resolveSpecGenerationReferences(dialog); } if (dialog.mode === 'character') { return [ ...(dialog.characterSpecReference ? [dialog.characterSpecReference] : []), ...(dialog.characterReferences ?? []), ]; } if (dialog.mode === 'ui-design') { return [ ...(dialog.uiDesignSpecReference ? [dialog.uiDesignSpecReference] : []), ...(dialog.generationReferences ?? []), ]; } if (dialog.mode === 'publication') { return dialog.publicationReferences ?? []; } return dialog.generationReferences ?? []; } async function normalizeVideoGenerationReferences( input: Parameters[0], references: CharacterReferenceImage[], projectId?: string | null, ) { const normalizeSources = async ( sources: string[] | undefined, mediaType: EditorMediaAssetUploadType, ) => { if (!sources?.length) { return undefined; } const matchingReferences = references.filter( (reference) => (reference.mediaType ?? 'image') === (mediaType === 'image' ? 'image' : mediaType), ); return Promise.all( sources.map((source, index) => resolveEditorGenerationMediaReference( matchingReferences[index] ? { ...matchingReferences[index], src: source } : { src: source }, mediaType, projectId, { allowRegisteredIds: false }, ), ), ); }; const [referenceImageSrcs, referenceVideoSrcs, referenceAudioSrcs] = await Promise.all([ normalizeSources(input.referenceImageSrcs, 'image'), normalizeSources(input.referenceVideoSrcs, 'video'), normalizeSources(input.referenceAudioSrcs, 'audio'), ]); return { ...input, ...(referenceImageSrcs ? { referenceImageSrcs } : {}), ...(referenceVideoSrcs ? { referenceVideoSrcs } : {}), ...(referenceAudioSrcs ? { referenceAudioSrcs } : {}), }; } function delay(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function queuedStateFromResponse( response: { queueState?: ExternalGenerationJobStatusRecord | null } | null, ) { return response?.queueState ?? null; } function projectHasUnresolvedGenerationDialog( project: EditorProjectSnapshot, dialogId: string | null | undefined, ) { return findCanvasGenerationDialogRecords(project, dialogId).some( isUnresolvedCanvasGenerationDialogRecord, ); } function notifyWalletBalanceMayHaveChanged(callback?: () => void) { callback?.(); } function notifyEditorGenerationWarning( warning: string | null | undefined, callback?: (message: string) => void, ) { const reason = warning?.trim(); if (!reason) { return; } callback?.(reason); } function resolveEditorGenerationWarningMessage( warning: string | null | undefined, sliceWarning: string | null | undefined, ) { // 风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存, // 这里按“通用在前、拆分在后”拼接成单条提示,不允许其中任何一条被丢弃。 const commonReason = warning?.trim(); const sliceReason = sliceWarning?.trim(); const prefixedSliceReason = sliceReason ? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}` : undefined; if (commonReason && prefixedSliceReason) { return `${commonReason} ${prefixedSliceReason}`; } return commonReason || prefixedSliceReason; } async function runEditorGenerationWithWalletRefresh( operation: Promise, onWalletBalanceMayHaveChanged?: () => void, ): Promise { try { const response = await operation; if ( !queuedStateFromResponse( response as { queueState?: ExternalGenerationJobStatusRecord | null }, ) ) { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); } return response; } catch (error) { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); throw error; } } async function waitForEditorGenerationQueue( queueState: ExternalGenerationJobStatusRecord, ): Promise { const startedAt = Date.now(); let current = queueState; while (current.status === 'queued' || current.status === 'running') { if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) { return null; } await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS); current = (await getExternalGenerationJobStatus(current.operationId)).job; } if (current.status === 'failed') { throw new Error(current.error?.trim() || '生成任务失败'); } return current; } export async function applyQueuedEditorGenerationProject( response: { queueState?: ExternalGenerationJobStatusRecord | null }, projectId: string | null | undefined, applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined, onQueuedGenerationTask?: () => void, onWalletBalanceMayHaveChanged?: () => void, onGenerationWarning?: (message: string) => void, completionDialogId?: string | null, transformProjectSnapshot?: ( project: EditorProjectSnapshot, ) => EditorProjectSnapshot, ) { const queueState = queuedStateFromResponse(response); if (!queueState) { return false; } onQueuedGenerationTask?.(); let terminalState: ExternalGenerationJobStatusRecord | null = null; try { terminalState = await waitForEditorGenerationQueue(queueState); } finally { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); } if (!terminalState) { return true; } notifyEditorGenerationWarning(terminalState.warning, onGenerationWarning); if (projectId && applyProjectSnapshot) { const applyLoadedProject = (project: EditorProjectSnapshot) => { applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project); }; const project = await loadEditorProject(projectId); applyLoadedProject(project); if (projectHasUnresolvedGenerationDialog(project, completionDialogId)) { await delay(EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS); applyLoadedProject(await loadEditorProject(projectId)); } } return true; } function positiveCanvasSize(value: number | null | undefined, fallback = 1) { const resolved = Number.isFinite(value) && (value ?? 0) > 0 ? value : fallback; return Math.max(1, Math.round(resolved ?? 1)); } function buildRightSideCanvasCompletionPlaceholder( sourceLayer: CanvasLayer, size?: { width?: number; height?: number; originalWidth?: number; originalHeight?: number; }, ): EditorCanvasGenerationCompletionInput['placeholder'] { const originalWidth = positiveCanvasSize( size?.originalWidth ?? size?.width, sourceLayer.originalWidth || sourceLayer.width || 1, ); const originalHeight = positiveCanvasSize( size?.originalHeight ?? size?.height, sourceLayer.originalHeight || sourceLayer.height || 1, ); const width = positiveCanvasSize(size?.width, originalWidth); const height = positiveCanvasSize(size?.height, originalHeight); return { x: sourceLayer.x + sourceLayer.width + 32, y: sourceLayer.y, width, height, originalWidth, originalHeight, }; } export function useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize, viewport, layerCounterRef, quickEditPanel, quickEditSourceLayer, quickEditSelectionState, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById, activeCanvasGenerationDialogId, backgroundMusicPromptAssist, soundEffectPromptAssist, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, captureCanvasHistory, updateSourceLayer, selectSingleLayer, fitLayers, setActiveTool, setActiveSidebarPanel, rememberImageModel, projectId, currentUserId, assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, }: GenerationSubmissionWorkflowOptions) { const currentBackgroundMusicSubmissionScopeRef = useRef({ currentUserId: currentUserId ?? null, projectId: projectId ?? null, activeCanvasGenerationDialogId, version: 0, mounted: false, }); useLayoutEffect(() => { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const scopeChanged = !Object.is(currentScope.currentUserId, currentUserId ?? null) || !Object.is(currentScope.projectId, projectId ?? null); currentBackgroundMusicSubmissionScopeRef.current = { currentUserId: currentUserId ?? null, projectId: projectId ?? null, activeCanvasGenerationDialogId, version: scopeChanged ? currentScope.version + 1 : currentScope.version, mounted: true, }; return () => { currentBackgroundMusicSubmissionScopeRef.current.mounted = false; }; }, [activeCanvasGenerationDialogId, currentUserId, projectId]); const isBackgroundMusicSubmissionUiTargetCurrent = useCallback( (scope: { currentUserId: string | null; projectId: string | null; dialogId: string; scopeVersion: number; }) => { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById(scope.dialogId); return ( currentScope.mounted && currentScope.version === scope.scopeVersion && Object.is(scope.currentUserId, currentScope.currentUserId) && Object.is(scope.projectId, currentScope.projectId) && currentDialog?.mode === 'audio-background-music' ); }, [getCanvasGenerationDialogById], ); const isSoundEffectSubmissionUiTargetCurrent = useCallback( (scope: { currentUserId: string | null; projectId: string | null; dialogId: string; scopeVersion: number; }) => { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById(scope.dialogId); return ( currentScope.mounted && currentScope.version === scope.scopeVersion && Object.is(scope.currentUserId, currentScope.currentUserId) && Object.is(scope.projectId, currentScope.projectId) && currentDialog?.mode === 'audio-sound-effect' ); }, [getCanvasGenerationDialogById], ); const isBackgroundMusicSubmissionAccountCurrent = useCallback( (scope: { currentUserId: string | null }) => { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; return ( currentScope.mounted && Object.is(scope.currentUserId, currentScope.currentUserId) ); }, [], ); const isBackgroundMusicSubmissionProjectCurrent = useCallback( (scope: { currentUserId: string | null; projectId: string | null }) => { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; return ( currentScope.mounted && Object.is(scope.currentUserId, currentScope.currentUserId) && Object.is(scope.projectId, currentScope.projectId) ); }, [], ); const addGeneratedLayersToCanvas = useCallback( (nextLayers: CanvasLayer[]) => { if (!nextLayers.length) { return; } captureCanvasHistory({ type: 'generate-image', count: nextLayers.length, }); appendCanvasLayersWithResources(nextLayers); nextLayers.forEach((layer) => { const asset = layer.generatedAssetSnapshot; if (asset) { upsertGeneratedAsset?.(asset); } }); }, [ appendCanvasLayersWithResources, captureCanvasHistory, upsertGeneratedAsset, ], ); const addGeneratedResultLayer = useCallback( ( generated: Parameters[0]['generated'], options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder']; assetKind?: CanvasLayer['assetKind']; title?: string; dialogId?: string; generationInputs?: CanvasGenerationInputs; } = {}, ) => { if ( options.dialogId && !hasCanvasGenerationDialogById(options.dialogId) ) { return; } layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const nextLayer = createGeneratedResultLayer({ generated, generatedIndex, canvasSize, viewport, sourceLayer: options.sourceLayer, frame: options.frame, assetKind: options.assetKind, title: options.title, generationInputs: options.generationInputs, }); addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (options.sourceLayer) { setGenerateDialog(null); setActiveTool('select'); } else if (options.dialogId) { updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({ ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, errorMessage: undefined, })); } if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer], { captureHistory: false }); } }, [ addGeneratedLayersToCanvas, canvasSize, fitLayers, layerCounterRef, selectSingleLayer, setActiveSidebarPanel, setActiveTool, setGenerateDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, viewport, ], ); const applyQuickEditResultToSourceLayer = useCallback( ( generated: Parameters< typeof applyImageEditResultToSourceLayer >[0]['generated'], sourceLayer: CanvasLayer, generationInputs: CanvasGenerationInputs, dialogId?: string, title?: string, ) => { updateSourceLayer( sourceLayer.id, (layer) => ({ ...applyImageEditResultToSourceLayer({ generated, sourceLayer: layer, generationInputs, }), ...(title ? { title } : {}), }), { fit: false, persist: false }, ); setQuickEditPanel(null); setActiveTool('select'); setActiveSidebarPanel('layers'); if (dialogId) { updateCanvasGenerationDialogById(dialogId, () => null); } }, [ setActiveSidebarPanel, setActiveTool, setQuickEditPanel, updateCanvasGenerationDialogById, updateSourceLayer, ], ); const addIconSpritesheetResultLayers = useCallback( ( generated: Parameters< typeof createIconSpritesheetResultLayers >[0]['generated'], iconResults: Parameters< typeof createIconSpritesheetResultLayers >[0]['iconResults'], generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, options: { spritesheetTitle?: string; } = {}, ) => { if (dialogId && !hasCanvasGenerationDialogById(dialogId)) { return []; } const startIndex = layerCounterRef.current + 1; const nextLayers = createIconSpritesheetResultLayers({ generated, iconResults, startIndex, canvasSize, viewport, generationInputs, frame, spritesheetTitle: options.spritesheetTitle, }); if (!nextLayers.length) { return []; } layerCounterRef.current += nextLayers.length; addGeneratedLayersToCanvas(nextLayers); selectSingleLayer(nextLayers[0]?.id ?? null); setActiveSidebarPanel('layers'); if (dialogId) { updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayers[0]?.id, errorMessage: undefined, })); } setActiveTool('select'); return nextLayers; }, [ addGeneratedLayersToCanvas, canvasSize, layerCounterRef, selectSingleLayer, setActiveSidebarPanel, setActiveTool, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, viewport, ], ); const addVideoResultLayer = useCallback( ( generated: Parameters[0]['generated'], title: string, generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, options: { sourceLayer?: CanvasLayer; assetKind?: CanvasLayer['assetKind']; } = {}, ) => { if (dialogId && !hasCanvasGenerationDialogById(dialogId)) { return; } layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const nextLayer = createVideoResultLayer({ generated, generatedIndex, title, canvasSize, viewport, generationInputs, frame, sourceLayer: options.sourceLayer, assetKind: options.assetKind, }); addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (dialogId) { updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, errorMessage: undefined, })); } setActiveTool('select'); }, [ addGeneratedLayersToCanvas, canvasSize, layerCounterRef, selectSingleLayer, setActiveSidebarPanel, setActiveTool, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, viewport, ], ); const addAudioResultLayer = useCallback( ( generated: Parameters[0]['generated'], title: string, generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, ) => { if (dialogId && !hasCanvasGenerationDialogById(dialogId)) { return; } layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const nextLayer = createAudioResultLayer({ generated, generatedIndex, title, canvasSize, viewport, generationInputs, frame, }); addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (dialogId) { updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, errorMessage: undefined, })); } setActiveTool('select'); }, [ addGeneratedLayersToCanvas, canvasSize, layerCounterRef, selectSingleLayer, setActiveSidebarPanel, setActiveTool, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, viewport, ], ); const addCharacterAnimationResultLayer = useCallback( ( generated: Parameters< typeof createCharacterAnimationResultLayer >[0]['generated'], title: string, generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, options: { sourceLayer?: CanvasLayer; } = {}, ) => { if (dialogId && !hasCanvasGenerationDialogById(dialogId)) { return; } layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const nextLayer = createCharacterAnimationResultLayer({ generated, generatedIndex, title, canvasSize, viewport, generationInputs, frame, sourceLayer: options.sourceLayer, }); if (!nextLayer) { return; } addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (dialogId) { updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, characterAnimationResult: generated, errorMessage: undefined, })); } setActiveTool('select'); }, [ addGeneratedLayersToCanvas, canvasSize, layerCounterRef, selectSingleLayer, setActiveSidebarPanel, setActiveTool, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, viewport, ], ); const extractUiDesignAssets = useCallback( async ( sourceLayer: CanvasLayer, options: UiDesignAssetExtractionOptions = {}, ) => { if (sourceLayer.assetKind !== 'ui-design') { return; } try { let sourceImageSrc: string; if (options.marks?.length) { const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl( sourceLayer.objectKey?.trim() || sourceLayer.src, ); const markedImageDataUrl = await renderUiDesignAssetExtractionMarkedImage({ source: renderSourceImageSrc, marks: options.marks, }); sourceImageSrc = await uploadEditorGenerationImageDataUrl( markedImageDataUrl, projectId, ); } else { sourceImageSrc = await resolveEditorGenerationMediaReference( sourceLayer, 'image', projectId, ); } const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR; const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL; const generationInputs = buildUiDesignAssetExtractionGenerationInputs( sourceLayer, options.references, { model: options.model ?? DEFAULT_IMAGE_MODEL, imageSize: resolveUiAssetExtractionGenerationPlan( options.marks?.length ?? 0, ).imageSize, }, ); const referenceImageSrcs = await Promise.all( (options.references ?? []).map((reference) => resolveEditorGenerationMediaReference( reference, 'image', projectId, ), ), ); const extractionPlan = resolveUiAssetExtractionGenerationPlan( options.marks?.length ?? 0, ); const extractionSize = resolveEditorImageGenerationPixelSize({ model: options.model ?? DEFAULT_IMAGE_MODEL, aspectRatio: extractionPlan.aspectRatio, imageSize: extractionPlan.imageSize, }); const extractionFrame = { x: sourceLayer.x + sourceLayer.width + 32, y: sourceLayer.y, width: extractionSize.width, height: extractionSize.height, originalWidth: extractionSize.width, originalHeight: extractionSize.height, }; const generated = await runEditorGenerationWithWalletRefresh( extractEditorUiDesignAssets({ sourceImageSrc, model: normalizeEditorImageModel( options.model ?? DEFAULT_IMAGE_MODEL, ), screenColor, segModel, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs, assetFolderId, spritesheetLabel: `${sourceLayer.title} 素材图集`, aspectRatio: extractionPlan.aspectRatio, imageSize: extractionPlan.imageSize, ...(projectId ? { canvasCompletion: { title: `${sourceLayer.title} 素材图集`, placeholder: extractionFrame, }, } : {}), }), onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( resolveEditorGenerationWarningMessage( generated.warning?.reason, generated.sliceWarning?.reason, ), onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); return; } const nextLayers = addIconSpritesheetResultLayers( generated, generated.iconImageSrcs ?? [], generationInputs, extractionFrame, undefined, { spritesheetTitle: `${sourceLayer.title} 素材图集`, }, ); if (nextLayers.length) { fitLayers([sourceLayer, ...nextLayers], { captureHistory: false }); } } catch (error) { if (options.suppressAlert) { throw error; } window.alert( error instanceof Error && error.message.trim() ? error.message : '提取素材失败', ); } }, [ addIconSpritesheetResultLayers, applyProjectSnapshot, fitLayers, projectId, assetFolderId, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ], ); const submitIconSpritesheetGeneration = useCallback( async (dialog: GenerateDialogState) => { if (dialog.mode !== 'icon') { return; } const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; const setSubmittingIconDialog = ( nextDialog: CanvasGenerationDialogState, ) => { updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); }; const submissionPlan = buildIconSpritesheetGenerationSubmissionPlan( dialog, layerCounterRef.current + 1, ); if (submissionPlan.ok === false) { if (canvasDialog) { setSubmittingIconDialog({ ...canvasDialog, status: 'failed', composerOpen: true, errorMessage: submissionPlan.errorMessage, }); } return; } if (!canvasDialog) { return; } setSubmittingIconDialog({ ...canvasDialog, iconDescriptions: submissionPlan.iconDescriptions, status: 'generating', composerOpen: false, errorMessage: undefined, }); try { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const referenceImageSrcs = await Promise.all( (dialog.generationReferences ?? []).map((reference) => resolveEditorGenerationMediaReference( reference, 'image', projectId, ), ), ); const generated = await runEditorGenerationWithWalletRefresh( generateEditorIconSpritesheet({ ...submissionPlan.input, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs: submissionPlan.generationInputs, assetFolderId, assetLabel: submissionPlan.resultTitle, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog.id, title: submissionPlan.resultTitle, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); rememberImageModel(submissionPlan.rememberImageModel); notifyEditorGenerationWarning( resolveEditorGenerationWarningMessage( generated.warning?.reason, generated.sliceWarning?.reason, ), onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); return; } addIconSpritesheetResultLayers( generated, generated.iconImageSrcs ?? [], submissionPlan.generationInputs, canvasCompletionPlaceholder, canvasDialog.id, { spritesheetTitle: submissionPlan.resultTitle }, ); } catch (error) { setSubmittingIconDialog({ ...canvasDialog, iconDescriptions: submissionPlan.iconDescriptions, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), }); } }, [ addIconSpritesheetResultLayers, applyProjectSnapshot, getGeneratingDialogPlaceholder, layerCounterRef, rememberImageModel, projectId, assetFolderId, updateCanvasGenerationDialogById, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ], ); const submitQuickEdit = useCallback(async () => { if (!quickEditPanel || !quickEditSourceLayer) { return; } if (!isQuickEditSupportedLayer(quickEditSourceLayer)) { setQuickEditPanel({ ...quickEditPanel, status: 'failed', errorMessage: '当前素材类型不支持快速编辑', }); return; } const quickEditResultTitle = resolveGenerationAssetLabel( quickEditPanel.assetLabel, `${quickEditSourceLayer.title} 快速编辑`, ); const quickEditReferences: CharacterReferenceImage[] = []; const quickEditAspectRatio = quickEditPanel.aspectRatio ?? inferEditorImageAspectRatio( quickEditSourceLayer.originalWidth, quickEditSourceLayer.originalHeight, ); const quickEditImageSize = quickEditPanel.imageSize ?? inferEditorImageSizeLabel( quickEditSourceLayer.originalWidth, quickEditSourceLayer.originalHeight, ); const normalizedQuickEditModel = normalizeEditorImageModel( quickEditPanel.model, ); const quickEditOutputSize = resolveEditorImageGenerationPixelSize({ model: normalizedQuickEditModel, aspectRatio: quickEditAspectRatio, imageSize: quickEditImageSize, }); const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`; const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; setQuickEditPanel({ ...quickEditPanel, aspectRatio: quickEditAspectRatio, imageSize: quickEditImageSize, size: quickEditSize, prompt: normalizedPrompt, status: 'generating', errorMessage: undefined, }); try { const extraReferenceImageSrcs = await Promise.all( quickEditReferences.map((reference) => resolveEditorGenerationMediaReference(reference, 'image', projectId), ), ); const quickEditSelectionMarks = quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id ? quickEditSelectionState.marks : []; const sourceReferenceId = resolveRegisteredEditorReferenceId(quickEditSourceLayer); let markedReferenceImageSrc: string | null = null; if (quickEditSelectionMarks.length > 0) { const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl( quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src, ); const markedImageDataUrl = await renderUiDesignAssetExtractionMarkedImage({ source: renderSourceImageSrc, marks: quickEditSelectionMarks, showIndexLabels: true, }); markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl( markedImageDataUrl, projectId, ); } const imageEditReferenceSrcs = [ ...extraReferenceImageSrcs, ...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []), ]; const generationInputs = buildQuickEditGenerationInputs( '快速编辑提示词', normalizedPrompt, quickEditSourceLayer, quickEditReferences, { model: normalizedQuickEditModel, aspectRatio: quickEditAspectRatio, imageSize: quickEditImageSize, }, ); const generated = await runEditorGenerationWithWalletRefresh( editEditorImage({ prompt: normalizedPrompt, sourceReferenceId, size: quickEditSize, model: normalizedQuickEditModel, aspectRatio: quickEditAspectRatio, imageSize: quickEditImageSize, ...(imageEditReferenceSrcs.length ? { referenceImageSrcs: imageEditReferenceSrcs } : {}), projectId, generationInputs, assetFolderId, assetLabel: quickEditResultTitle, targetLayerId: quickEditSourceLayer.id, }), onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( generated.warning?.reason, onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, ) ) { setQuickEditPanel(null); setActiveTool('select'); setActiveSidebarPanel('layers'); return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); setQuickEditPanel(null); setActiveTool('select'); setActiveSidebarPanel('layers'); return; } applyQuickEditResultToSourceLayer( generated, quickEditSourceLayer, generationInputs, undefined, quickEditResultTitle, ); } catch (error) { setQuickEditPanel({ ...quickEditPanel, aspectRatio: quickEditAspectRatio, imageSize: quickEditImageSize, size: quickEditSize, prompt: normalizedPrompt, status: 'failed', errorMessage: resolveImageGenerationErrorMessage(error), }); } }, [ applyQuickEditResultToSourceLayer, applyProjectSnapshot, projectId, assetFolderId, quickEditPanel, quickEditSourceLayer, quickEditSelectionState, setActiveSidebarPanel, setActiveTool, setQuickEditPanel, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ]); const resetProjectBackedGenerationDialog = useCallback( (canvasDialog: CanvasGenerationDialogState | null) => { if (!canvasDialog || !projectId) { return false; } updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => currentDialog.generatedLayerId ? currentDialog : { ...currentDialog, status: 'idle', composerOpen: true, errorMessage: undefined, }, ); return true; }, [projectId, updateCanvasGenerationDialogById], ); const submitImageGeneration = useCallback( async (dialog: GenerateDialogState) => { const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; const backgroundMusicDialog = canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null; const soundEffectDialog = canvasDialog?.mode === 'audio-sound-effect' ? canvasDialog : null; if (dialog.mode === 'audio-background-music' && !backgroundMusicDialog) { return; } if (dialog.mode === 'audio-sound-effect' && !soundEffectDialog) { return; } const backgroundMusicClaim = backgroundMusicDialog ? backgroundMusicPromptAssist.beginSubmission(backgroundMusicDialog.id) : null; if (backgroundMusicDialog && !backgroundMusicClaim) { return; } const soundEffectClaim = soundEffectDialog ? soundEffectPromptAssist.beginSubmission(soundEffectDialog.id) : null; if (soundEffectDialog && !soundEffectClaim) { const validation = validateSoundEffectPrompt(soundEffectDialog.prompt); if (!validation.ok) { updateCanvasGenerationDialogById( soundEffectDialog.id, (currentDialog) => currentDialog.mode === 'audio-sound-effect' ? { ...currentDialog, prompt: validation.prompt, status: 'failed', composerOpen: true, errorMessage: validation.reason === 'empty' ? '音效描述不能为空' : '音效描述不能超过 2048 个字符', } : currentDialog, ); } return; } const backgroundMusicSubmission = backgroundMusicDialog && backgroundMusicClaim ? { currentUserId: currentUserId ?? null, projectId: projectId ?? null, dialogId: backgroundMusicDialog.id, operation: backgroundMusicClaim.operation, prompt: backgroundMusicClaim.prompt, scopeVersion: currentBackgroundMusicSubmissionScopeRef.current.version, } : null; const soundEffectSubmission = soundEffectDialog && soundEffectClaim ? { currentUserId: currentUserId ?? null, projectId: projectId ?? null, dialogId: soundEffectDialog.id, ...soundEffectClaim, scopeVersion: currentBackgroundMusicSubmissionScopeRef.current.version, } : null; const normalizedPrompt = backgroundMusicSubmission?.prompt ?? soundEffectSubmission?.prompt ?? resolveImageGenerationDialogPrompt(dialog); if ( !backgroundMusicSubmission && !soundEffectSubmission && canvasDialog ) { updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ ...currentDialog, prompt: normalizedPrompt, status: 'generating', composerOpen: false, })); } else if (!backgroundMusicSubmission && !soundEffectSubmission) { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'generating', composerOpen: dialog.mode === 'edit', }); } let backgroundMusicSubmissionAccepted = false; let backgroundMusicSubmissionUiOwned = false; let backgroundMusicQueueAcceptedWhileCurrentAndOpen = false; let soundEffectSubmissionAccepted = false; let soundEffectSubmissionUiOwned = false; let soundEffectQueueAcceptedWhileCurrentAndOpen = false; try { const dialogForSubmission = soundEffectSubmission ? { ...dialog, prompt: soundEffectSubmission.prompt, soundDurationMode: soundEffectSubmission.durationMode, soundDurationSeconds: soundEffectSubmission.manualDurationSeconds, soundLoop: soundEffectSubmission.loop, } : dialog; const submissionPlan = buildImageGenerationSubmissionPlan({ dialog: dialogForSubmission, layers, nextGeneratedIndex: layerCounterRef.current + 1, canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt, }); if (submissionPlan.kind === 'edit') { const sourceReferenceId = resolveRegisteredEditorReferenceId( submissionPlan.sourceLayer, ); const editPlaceholderSize = getCanvasCompletionPlaceholderSizeFromPlan({ sourceLayer: submissionPlan.sourceLayer, outputSize: submissionPlan.editInput.size, }); const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const editCanvasCompletionPlaceholder = canvasCompletionPlaceholder ?? buildRightSideCanvasCompletionPlaceholder( submissionPlan.sourceLayer, { width: editPlaceholderSize.width, height: editPlaceholderSize.height, originalWidth: editPlaceholderSize.width, originalHeight: editPlaceholderSize.height, }, ); const generated = await runEditorGenerationWithWalletRefresh( editEditorImage({ prompt: submissionPlan.normalizedPrompt, sourceReferenceId, ...submissionPlan.editInput, projectId, generationInputs: submissionPlan.generationInputs, assetFolderId, assetLabel: submissionPlan.resultTitle, ...(projectId && editCanvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.resultTitle, placeholder: editCanvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); return; } addGeneratedResultLayer(generated, { sourceLayer: submissionPlan.sourceLayer, title: submissionPlan.resultTitle, generationInputs: submissionPlan.generationInputs, }); } else if (submissionPlan.kind === 'quick-edit') { const sourceReferenceId = resolveRegisteredEditorReferenceId( submissionPlan.sourceLayer, ); const quickEditSelectionMarks = quickEditSelectionState?.sourceLayerId === submissionPlan.sourceLayer.id ? quickEditSelectionState.marks : []; let markedReferenceImageSrc: string | null = null; if (quickEditSelectionMarks.length > 0) { const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl( submissionPlan.sourceLayer.objectKey?.trim() || submissionPlan.sourceLayer.src, ); const markedImageDataUrl = await renderUiDesignAssetExtractionMarkedImage({ source: renderSourceImageSrc, marks: quickEditSelectionMarks, showIndexLabels: true, }); markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl( markedImageDataUrl, projectId, ); } const normalizedReferenceImageSrcs = await Promise.all( (submissionPlan.editInput.referenceImageSrcs ?? []).map( (referenceImageSrc, index) => resolveEditorGenerationMediaReference( dialog.generationReferences?.[index] ? { ...dialog.generationReferences[index], src: referenceImageSrc, } : { src: referenceImageSrc }, 'image', projectId, ), ), ); const referenceImageSrcs = [ ...normalizedReferenceImageSrcs, ...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []), ]; const generated = await runEditorGenerationWithWalletRefresh( editEditorImage({ prompt: submissionPlan.normalizedPrompt, sourceReferenceId, ...submissionPlan.editInput, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, targetLayerId: submissionPlan.sourceLayer.id, }), onWalletBalanceMayHaveChanged, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) { upsertGeneratedAsset?.(generated.asset); } return; } applyQuickEditResultToSourceLayer( generated, submissionPlan.sourceLayer, submissionPlan.result.generationInputs, canvasDialog?.id, submissionPlan.result.title, ); } else if (submissionPlan.kind === 'icon-spec') { const iconSpecInput = submissionPlan.input; const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const generated = await runEditorGenerationWithWalletRefresh( generateEditorIconSpec({ ...iconSpecInput, projectId, assetFolderId, assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.result.title, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( generated.warning?.reason, onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, canvasDialog?.id, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) { upsertGeneratedAsset?.(generated.asset); } return; } if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addGeneratedResultLayer(generated, { frame: canvasCompletionPlaceholder, assetKind: submissionPlan.result.assetKind, title: submissionPlan.result.title, dialogId: canvasDialog?.id, generationInputs: generated.asset?.generationInputs ?? submissionPlan.result.generationInputs, }); } else if (submissionPlan.kind === 'video') { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const videoGenerationInput = await normalizeVideoGenerationReferences( submissionPlan.input, dialog.generationReferences ?? [], projectId, ); const generated = await runEditorGenerationWithWalletRefresh( generateEditorVideo({ ...videoGenerationInput, projectId, generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, assetKind: 'video', ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.result.title, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) { upsertGeneratedAsset?.(generated.asset); } return; } if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addVideoResultLayer( generated, submissionPlan.result.title, submissionPlan.result.generationInputs, canvasCompletionPlaceholder, canvasDialog?.id, ); } else if (submissionPlan.kind === 'audio') { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); // 直连回包的钱包刷新由共享 wrapper 触发,而 wrapper 不持有本次 submission scope。 // 这里补上与排队路径和 BGM 分支同一道账号门禁:切号后迟到的旧回包不得刷新新账号钱包。 const refreshSoundEffectWalletBalance = soundEffectSubmission && onWalletBalanceMayHaveChanged ? () => { if ( isBackgroundMusicSubmissionAccountCurrent( soundEffectSubmission, ) ) { onWalletBalanceMayHaveChanged(); } } : onWalletBalanceMayHaveChanged; const generated = submissionPlan.audioKind === 'sound-effect' ? await runEditorGenerationWithWalletRefresh( generateEditorSoundEffect({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.result.title, placeholder: canvasCompletionPlaceholder, }, } : {}), }), refreshSoundEffectWalletBalance, ) : await generateEditorBackgroundMusic({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.result.title, placeholder: canvasCompletionPlaceholder, }, } : {}), }); const isBackgroundMusic = submissionPlan.audioKind === 'background-music'; const isSoundEffect = submissionPlan.audioKind === 'sound-effect'; const backgroundMusicQueueState = isBackgroundMusic ? queuedStateFromResponse(generated) : null; const soundEffectQueueState = isSoundEffect ? queuedStateFromResponse(generated) : null; if (isBackgroundMusic && backgroundMusicSubmission) { backgroundMusicSubmissionAccepted = true; const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById( backgroundMusicSubmission.dialogId, ); backgroundMusicQueueAcceptedWhileCurrentAndOpen = Boolean(backgroundMusicQueueState) && currentScope.activeCanvasGenerationDialogId === backgroundMusicSubmission.dialogId && currentDialog?.composerOpen !== false; backgroundMusicSubmissionUiOwned = backgroundMusicPromptAssist.finishSubmission({ operation: backgroundMusicSubmission.operation, accepted: true, }); if ( backgroundMusicQueueState && backgroundMusicSubmissionUiOwned && isBackgroundMusicSubmissionUiTargetCurrent( backgroundMusicSubmission, ) ) { updateCanvasGenerationDialogById( backgroundMusicSubmission.dialogId, (currentDialog) => currentDialog.mode === 'audio-background-music' ? { ...currentDialog, prompt: backgroundMusicSubmission.prompt, status: 'generating', composerOpen: false, errorMessage: undefined, } : currentDialog, ); } if ( !backgroundMusicQueueState && isBackgroundMusicSubmissionAccountCurrent( backgroundMusicSubmission, ) ) { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); } } if (isSoundEffect && soundEffectSubmission) { soundEffectSubmissionAccepted = true; const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById( soundEffectSubmission.dialogId, ); soundEffectQueueAcceptedWhileCurrentAndOpen = Boolean(soundEffectQueueState) && currentScope.activeCanvasGenerationDialogId === soundEffectSubmission.dialogId && currentDialog?.composerOpen !== false; soundEffectSubmissionUiOwned = soundEffectPromptAssist.finishSubmission({ operation: soundEffectSubmission.operation, accepted: true, }); if ( soundEffectQueueState && soundEffectSubmissionUiOwned && isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission) ) { updateCanvasGenerationDialogById( soundEffectSubmission.dialogId, (currentDialog) => currentDialog.mode === 'audio-sound-effect' ? { ...currentDialog, prompt: soundEffectSubmission.prompt, soundDurationMode: soundEffectSubmission.durationMode, soundDurationSeconds: soundEffectSubmission.manualDurationSeconds, soundLoop: soundEffectSubmission.loop, status: 'generating', composerOpen: false, errorMessage: undefined, } : currentDialog, ); } } if ( await applyQueuedEditorGenerationProject( generated, projectId, backgroundMusicSubmission && applyProjectSnapshot ? (projectSnapshot) => { if ( backgroundMusicSubmissionUiOwned && isBackgroundMusicSubmissionUiTargetCurrent( backgroundMusicSubmission, ) ) { applyProjectSnapshot(projectSnapshot); } } : soundEffectSubmission && applyProjectSnapshot ? (projectSnapshot) => { if ( soundEffectSubmissionUiOwned && isSoundEffectSubmissionUiTargetCurrent( soundEffectSubmission, ) ) { applyProjectSnapshot(projectSnapshot); } } : applyProjectSnapshot, backgroundMusicSubmission && onQueuedGenerationTask ? () => { if ( isBackgroundMusicSubmissionProjectCurrent( backgroundMusicSubmission, ) ) { onQueuedGenerationTask(); } } : soundEffectSubmission && onQueuedGenerationTask ? () => { if ( isBackgroundMusicSubmissionProjectCurrent( soundEffectSubmission, ) ) { onQueuedGenerationTask(); } } : onQueuedGenerationTask, backgroundMusicSubmission && onWalletBalanceMayHaveChanged ? () => { if ( isBackgroundMusicSubmissionAccountCurrent( backgroundMusicSubmission, ) ) { onWalletBalanceMayHaveChanged(); } } : soundEffectSubmission && onWalletBalanceMayHaveChanged ? () => { if ( isBackgroundMusicSubmissionAccountCurrent( soundEffectSubmission, ) ) { onWalletBalanceMayHaveChanged(); } } : onWalletBalanceMayHaveChanged, ) ) { return; } if ( backgroundMusicSubmission && (!backgroundMusicSubmissionUiOwned || !isBackgroundMusicSubmissionUiTargetCurrent( backgroundMusicSubmission, )) ) { return; } if ( soundEffectSubmission && (!soundEffectSubmissionUiOwned || !isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission)) ) { return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) { upsertGeneratedAsset?.(generated.asset); } return; } if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addAudioResultLayer( generated, submissionPlan.result.title, submissionPlan.result.generationInputs, canvasCompletionPlaceholder, canvasDialog?.id, ); } else if (submissionPlan.kind === 'scene') { const sceneGenerationInput = await normalizeGenerationReferenceImages( submissionPlan.input, resolveImageGenerationDialogReferences(dialog), projectId, ); const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const generated = await runEditorGenerationWithWalletRefresh( generateEditorScene({ ...sceneGenerationInput, projectId, generationInputs: submissionPlan.input.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: submissionPlan.result.title, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( generated.warning?.reason, onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, canvasDialog?.id, ) ) { return; } if (submissionPlan.rememberImageModel) { rememberImageModel(submissionPlan.rememberImageModel); } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) upsertGeneratedAsset?.(generated.asset); return; } if (resetProjectBackedGenerationDialog(canvasDialog)) return; addGeneratedResultLayer(generated, { frame: canvasCompletionPlaceholder, assetKind: submissionPlan.result.assetKind, title: submissionPlan.result.title, dialogId: canvasDialog?.id, generationInputs: submissionPlan.result.generationInputs, }); } else { const imageGenerationInput = await normalizeGenerationReferenceImages( submissionPlan.input, resolveImageGenerationDialogReferences(dialog), projectId, ); const resultTitle = submissionPlan.result.title ?? `生成图片 ${layerCounterRef.current + 1}`; const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const generated = await runEditorGenerationWithWalletRefresh( generateEditorImage({ ...imageGenerationInput, projectId, assetKind: submissionPlan.result.assetKind, generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: resultTitle, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog?.id, title: resultTitle, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( generated.warning?.reason, onGenerationWarning, ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning, ) ) { return; } if (submissionPlan.rememberImageModel) { rememberImageModel(submissionPlan.rememberImageModel); } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); if (generated.asset) { upsertGeneratedAsset?.(generated.asset); } return; } if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addGeneratedResultLayer(generated, { frame: canvasCompletionPlaceholder, assetKind: submissionPlan.result.assetKind, title: resultTitle, dialogId: canvasDialog?.id, generationInputs: submissionPlan.result.generationInputs, }); } } catch (error) { if (backgroundMusicSubmission) { if (!backgroundMusicSubmissionAccepted) { backgroundMusicSubmissionUiOwned = backgroundMusicPromptAssist.finishSubmission({ operation: backgroundMusicSubmission.operation, accepted: false, }); if ( isBackgroundMusicSubmissionAccountCurrent( backgroundMusicSubmission, ) ) { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); } } if ( backgroundMusicSubmissionUiOwned && isBackgroundMusicSubmissionUiTargetCurrent( backgroundMusicSubmission, ) ) { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById( backgroundMusicSubmission.dialogId, ); const shouldReopenComposer = currentScope.activeCanvasGenerationDialogId === backgroundMusicSubmission.dialogId && (currentDialog?.composerOpen !== false || backgroundMusicQueueAcceptedWhileCurrentAndOpen); updateCanvasGenerationDialogById( backgroundMusicSubmission.dialogId, (latestDialog) => latestDialog.mode === 'audio-background-music' ? { ...latestDialog, prompt: backgroundMusicSubmission.prompt, status: 'failed', composerOpen: shouldReopenComposer ? true : latestDialog.composerOpen, errorMessage: resolveImageGenerationErrorMessage(error), } : latestDialog, ); } } else if (soundEffectSubmission) { if (!soundEffectSubmissionAccepted) { soundEffectSubmissionUiOwned = soundEffectPromptAssist.finishSubmission({ operation: soundEffectSubmission.operation, accepted: false, }); } if ( soundEffectSubmissionUiOwned && isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission) ) { const currentScope = currentBackgroundMusicSubmissionScopeRef.current; const currentDialog = getCanvasGenerationDialogById( soundEffectSubmission.dialogId, ); const shouldReopenComposer = currentScope.activeCanvasGenerationDialogId === soundEffectSubmission.dialogId && (currentDialog?.composerOpen !== false || soundEffectQueueAcceptedWhileCurrentAndOpen); updateCanvasGenerationDialogById( soundEffectSubmission.dialogId, (latestDialog) => latestDialog.mode === 'audio-sound-effect' ? { ...latestDialog, prompt: soundEffectSubmission.prompt, soundDurationMode: soundEffectSubmission.durationMode, soundDurationSeconds: soundEffectSubmission.manualDurationSeconds, soundLoop: soundEffectSubmission.loop, status: 'failed', composerOpen: shouldReopenComposer ? true : latestDialog.composerOpen, errorMessage: resolveImageGenerationErrorMessage(error), } : latestDialog, ); } } else if (canvasDialog) { updateCanvasGenerationDialogById(canvasDialog.id, () => ({ ...canvasDialog, prompt: normalizedPrompt, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), })); } else { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), }); } } }, [ addGeneratedResultLayer, addAudioResultLayer, addVideoResultLayer, applyQuickEditResultToSourceLayer, backgroundMusicPromptAssist, currentUserId, getCanvasGenerationDialogById, getGeneratingDialogPlaceholder, isBackgroundMusicSubmissionAccountCurrent, isBackgroundMusicSubmissionProjectCurrent, isBackgroundMusicSubmissionUiTargetCurrent, isSoundEffectSubmissionUiTargetCurrent, layerCounterRef, layers, quickEditSelectionState, rememberImageModel, resetProjectBackedGenerationDialog, projectId, assetFolderId, applyProjectSnapshot, setGenerateDialog, soundEffectPromptAssist, updateCanvasGenerationDialogById, upsertGeneratedAsset, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ], ); const submitCharacterAnimation = useCallback(async () => { if (!characterAnimationPanel || !characterAnimationSourceLayer) { return; } if ( characterAnimationPanel.status === 'generating' || (characterAnimationDialog?.mode === 'character-animation' && characterAnimationDialog.status === 'generating') ) { return; } const canvasDialog = characterAnimationDialog; try { const submissionPlan = buildCharacterAnimationSubmissionPlan({ panel: characterAnimationPanel, sourceLayer: characterAnimationSourceLayer, }); if (canvasDialog?.mode === 'character-animation') { updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ ...currentDialog, prompt: submissionPlan.promptText, characterAnimationResolution: characterAnimationPanel.resolution, characterAnimationRatio: characterAnimationPanel.ratio, characterAnimationFrameCount: characterAnimationPanel.frameCount, characterAnimationDurationSeconds: characterAnimationPanel.durationSeconds, assetLabel: submissionPlan.resultTitle, characterAnimationResult: undefined, status: 'generating', composerOpen: false, errorMessage: undefined, })); } const nextPanel = { ...characterAnimationPanel, promptText: submissionPlan.promptText, assetLabel: submissionPlan.resultTitle, status: 'generating' as const, errorMessage: undefined, result: undefined, }; if (!canvasDialog) { setCharacterAnimationPanel(nextPanel); } const generationInputs = buildCharacterAnimationGenerationInputs( submissionPlan.promptText, characterAnimationSourceLayer, { resolution: characterAnimationPanel.resolution, ratio: characterAnimationPanel.ratio, frameCount: characterAnimationPanel.frameCount, durationSeconds: characterAnimationPanel.durationSeconds, }, ); const canvasCompletionPlaceholder = canvasDialog ? getGeneratingDialogPlaceholder(canvasDialog) : undefined; const sourceImageSrc = await resolveEditorGenerationMediaReference( characterAnimationSourceLayer, 'image', projectId, { allowRegisteredIds: false, requireImageObjectReference: true, }, ); const result = await runEditorGenerationWithWalletRefresh( generateEditorCharacterAnimation({ ...submissionPlan.input, sourceImageSrc, projectId, generationInputs, sourceResourceId: characterAnimationSourceLayer.resourceId, assetFolderId, ...(canvasDialog && projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog.id, title: submissionPlan.resultTitle, placeholder: canvasCompletionPlaceholder, }, } : {}), }), onWalletBalanceMayHaveChanged, ); if ( await applyQueuedEditorGenerationProject( result, projectId, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, ) ) { return; } if (result.project && applyProjectSnapshot) { applyProjectSnapshot(result.project); return; } if (canvasDialog) { if (projectId && !result.resource) { throw new Error('角色动作画布完成缺少正式项目资源。'); } if (!projectId && !result.asset) { throw new Error('角色动作生成缺少正式账号素材。'); } addCharacterAnimationResultLayer( result, submissionPlan.resultTitle, generationInputs, canvasCompletionPlaceholder, canvasDialog.id, { sourceLayer: characterAnimationSourceLayer, }, ); return; } setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, status: 'completed', result, } : currentPanel, ); } catch (error) { if (canvasDialog) { updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ ...currentDialog, prompt: characterAnimationPanel.promptText.trim(), characterAnimationResolution: characterAnimationPanel.resolution, characterAnimationRatio: characterAnimationPanel.ratio, characterAnimationFrameCount: characterAnimationPanel.frameCount, characterAnimationDurationSeconds: characterAnimationPanel.durationSeconds, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage( error, '生成角色动画失败', ), })); return; } setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, promptText: characterAnimationPanel.promptText.trim(), status: 'failed', errorMessage: resolveImageGenerationErrorMessage( error, '生成角色动画失败', ), } : currentPanel, ); } }, [ addCharacterAnimationResultLayer, assetFolderId, characterAnimationDialog, characterAnimationPanel, characterAnimationSourceLayer, getGeneratingDialogPlaceholder, applyProjectSnapshot, projectId, setCharacterAnimationPanel, updateCanvasGenerationDialogById, onWalletBalanceMayHaveChanged, onQueuedGenerationTask, ]); return useMemo( () => ({ submitIconSpritesheetGeneration, extractUiDesignAssets, submitQuickEdit, submitImageGeneration, submitCharacterAnimation, }), [ submitCharacterAnimation, extractUiDesignAssets, submitIconSpritesheetGeneration, submitImageGeneration, submitQuickEdit, ], ); }