diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index f7fe6af2e..0ad137e09 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -23,6 +23,7 @@ import { createPublicationGenerationDialogDraft, createQuickEditPanelDraft, createRedrawPanelDraft, + createSameSourceGenerationDialogDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, @@ -490,6 +491,108 @@ describe('ImageCanvasGenerationDialogModel', () => { ).toBe(''); }); + it('creates same-source generator drafts for generated image layers', () => { + const canvasSize = { width: 960, height: 720 }; + const viewport = { x: 0, y: 0, scale: 1 }; + const characterDraft = createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '红发骑士' }], + references: [], + }, + }), + canvasSize, + viewport, + mode: 'redraw', + }); + + expect(characterDraft).toMatchObject({ + mode: 'character', + prompt: '红发骑士', + sourceLayerId: 'layer-source', + placeholder: { + x: 472, + y: 140, + }, + }); + + const publicationDraft = createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + assetKind: 'publication-material', + originalWidth: 720, + originalHeight: 1280, + generationInputs: { + fields: [ + { title: '游戏名', value: '星海远征' }, + { title: '游戏分类', value: '冒险' }, + { title: '一句话描述游戏', value: '驾驶飞船探索星云' }, + ], + references: [], + }, + }), + canvasSize, + viewport, + mode: 'redraw', + }); + + expect(publicationDraft).toMatchObject({ + mode: 'publication', + publicationWorkflowId: 'publication-detail-gallery', + publicationGameInfo: { + gameName: '星海远征', + gameCategories: '冒险', + gameDescription: '驾驶飞船探索星云', + }, + }); + + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer(), + canvasSize, + viewport, + mode: 'redraw', + }), + ).toBeNull(); + }); + + it('restores the plain image generator when a source generation dialog exists', () => { + const canvasSize = { width: 960, height: 720 }; + const viewport = { x: 0, y: 0, scale: 1 }; + + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + prompt: '【系统内置】普通提示词', + generationInputs: { + fields: [{ title: '生成提示词', value: '普通图' }], + references: [], + }, + }), + canvasSize, + viewport, + sourceDialog: { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '普通图', + status: 'idle', + generatedLayerId: 'layer-source', + imageModel: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '2K', + }, + mode: 'redraw', + }), + ).toMatchObject({ + mode: 'generate', + prompt: '普通图', + sourceLayerId: 'layer-source', + imageModel: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '2K', + }); + }); + it('normalizes legacy nanobanana model aliases in quick edit drafts', () => { expect( createQuickEditPanelDraft( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index fe33bb826..24478b689 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -18,7 +18,6 @@ import { CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE, CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE, createCanvasLayerReference, - DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, DEFAULT_PUBLICATION_GAME_INFO, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, @@ -45,6 +44,14 @@ import { import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; type CanvasSize = { width: number; height: number }; +type SourceGenerationDialogDraftContext = { + sourceLayer: CanvasLayer; + canvasSize: CanvasSize; + viewport: CanvasViewport; + sourceDialog?: CanvasGenerationDialogState | null; + mode: 'quick-edit' | 'redraw'; + sourceAnimationLayer?: CanvasLayer | null; +}; const VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, @@ -156,6 +163,18 @@ function resolveImageDimensionDefaults(imageModel: string) { }; } +function getGenerationInputFieldValues(layer: CanvasLayer) { + const values = new Map(); + for (const field of layer.generationInputs?.fields ?? []) { + const title = field.title.trim().toLowerCase(); + const value = field.value.trim(); + if (title && value && !values.has(title)) { + values.set(title, value); + } + } + return values; +} + export function createGenerateDialogDraft({ canvasSize, viewport, @@ -420,7 +439,7 @@ export function createBackgroundMusicGenerationDialogDraft({ function findGenerationInputFieldValue( layer: CanvasLayer, - titles: string[], + titles: readonly string[], ) { const normalizedTitles = new Set(titles.map((title) => title.toLowerCase())); return ( @@ -594,6 +613,423 @@ export function createVideoRedrawGenerationDialogDraft( }, }; } + +function resolveGeneratedSourceDialogMode({ + sourceLayer, + sourceDialog, +}: { + sourceLayer: CanvasLayer; + sourceDialog?: CanvasGenerationDialogState | null; +}): CanvasGenerationDialogState['mode'] | null { + if (sourceDialog) { + return sourceDialog.mode; + } + if (sourceLayer.assetKind === 'character') { + return 'character'; + } + if (sourceLayer.assetKind === 'ui-design') { + return 'ui-design'; + } + if (sourceLayer.assetKind === 'publication-material') { + return 'publication'; + } + if ( + sourceLayer.assetKind === 'icon' || + sourceLayer.assetKind === 'icon-spritesheet' + ) { + return 'icon'; + } + if ( + sourceLayer.assetKind === 'spec' || + sourceLayer.assetKind === 'icon-spec' + ) { + return 'spec'; + } + if (sourceLayer.assetKind === 'character-animation') { + return 'character-animation'; + } + return null; +} + +function getSourceDraftPrompt({ + sourceLayer, + mode, +}: { + sourceLayer: CanvasLayer; + mode: SourceGenerationDialogDraftContext['mode']; +}) { + if (mode === 'quick-edit') { + return ''; + } + return resolveUserGenerationPromptSnapshot(sourceLayer); +} + +function buildSourceSidePlaceholder(sourceLayer: CanvasLayer) { + return { + x: sourceLayer.x + sourceLayer.width + 32, + y: sourceLayer.y, + }; +} + +function placeDraftBesideSourceLayer>( + draft: T, + sourceLayer: CanvasLayer, +): T { + if (!draft.placeholder) { + return draft; + } + return { + ...draft, + placeholder: { + ...draft.placeholder, + ...buildSourceSidePlaceholder(sourceLayer), + }, + }; +} + +function restoreSharedImageOptions( + draft: Omit, + sourceLayer: CanvasLayer, + sourceDialog?: CanvasGenerationDialogState | null, +): Omit { + return { + ...draft, + imageModel: sourceDialog?.imageModel ?? draft.imageModel, + aspectRatio: + sourceDialog?.aspectRatio ?? + draft.aspectRatio ?? + inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ), + imageSize: + sourceDialog?.imageSize ?? + draft.imageSize ?? + inferEditorImageSizeLabel( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ), + }; +} + +function resolveSpecTypeFromSourceLayer( + sourceLayer: CanvasLayer, + sourceDialog?: CanvasGenerationDialogState | null, +): SpecGenerationType { + if (sourceDialog?.mode === 'spec' && sourceDialog.specType) { + return sourceDialog.specType; + } + if (sourceLayer.assetKind === 'icon-spec' || isIconSpecReferenceLayer(sourceLayer)) { + return 'icon'; + } + const fields = getGenerationInputFieldValues(sourceLayer); + if (fields.has('自定义规范提示词')) { + return 'custom'; + } + if (fields.has('头身比') || fields.has('角色视角')) { + return 'character'; + } + return 'ui'; +} + +function restoreSpecValuesFromLayer( + draft: Omit, + sourceLayer: CanvasLayer, + specType: SpecGenerationType, + sourceDialog?: CanvasGenerationDialogState | null, +): Omit { + const fields = getGenerationInputFieldValues(sourceLayer); + const defaultValues = DEFAULT_SPEC_FORM_VALUES[specType]; + return { + ...draft, + specValues: { + ...defaultValues, + ...sourceDialog?.specValues, + playSetting: + fields.get('玩法设定') ?? + sourceDialog?.specValues?.playSetting ?? + defaultValues.playSetting, + artStyle: + fields.get('美术风格') ?? + sourceDialog?.specValues?.artStyle ?? + defaultValues.artStyle, + bodyRatio: + fields.get('头身比') ?? + sourceDialog?.specValues?.bodyRatio ?? + defaultValues.bodyRatio, + characterView: + fields.get('角色视角') ?? + sourceDialog?.specValues?.characterView ?? + defaultValues.characterView, + customPrompt: + fields.get('自定义规范提示词') ?? + sourceDialog?.specValues?.customPrompt ?? + defaultValues.customPrompt, + }, + }; +} + +function resolvePublicationWorkflowIdFromLayer( + sourceLayer: CanvasLayer, + sourceDialog?: CanvasGenerationDialogState | null, +): PublicationMaterialsWorkflowId { + if (sourceDialog?.mode === 'publication' && sourceDialog.publicationWorkflowId) { + return sourceDialog.publicationWorkflowId; + } + const ratio = inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + if (ratio === '9:16') { + return 'publication-detail-gallery'; + } + if (ratio === '16:9') { + return 'publication-promo-poster'; + } + return 'publication-cover-image'; +} + +function restorePublicationGameInfoFromLayer( + draft: Omit, + sourceLayer: CanvasLayer, + sourceDialog?: CanvasGenerationDialogState | null, +): Omit { + const fields = getGenerationInputFieldValues(sourceLayer); + return { + ...draft, + publicationGameInfo: { + ...DEFAULT_PUBLICATION_GAME_INFO, + ...sourceDialog?.publicationGameInfo, + gameName: + fields.get('游戏名') ?? + sourceDialog?.publicationGameInfo?.gameName ?? + DEFAULT_PUBLICATION_GAME_INFO.gameName, + gameCategories: + fields.get('游戏分类') ?? + sourceDialog?.publicationGameInfo?.gameCategories ?? + DEFAULT_PUBLICATION_GAME_INFO.gameCategories, + gameDescription: + fields.get('一句话描述游戏') ?? + sourceDialog?.publicationGameInfo?.gameDescription ?? + DEFAULT_PUBLICATION_GAME_INFO.gameDescription, + }, + publicationReferences: sourceDialog?.publicationReferences ?? [], + }; +} + +function restoreIconDescriptionsFromLayer( + draft: Omit, + sourceLayer: CanvasLayer, + sourceDialog?: CanvasGenerationDialogState | null, +): Omit { + const descriptions = parseIconDescriptionsText( + findGenerationInputFieldValue(sourceLayer, ['素材描述']) ?? + sourceDialog?.prompt ?? + '', + ); + return { + ...draft, + prompt: descriptions.join('\n'), + iconDescriptions: descriptions.length + ? descriptions + : (sourceDialog?.iconDescriptions ?? draft.iconDescriptions), + iconSpecReference: sourceDialog?.iconSpecReference ?? draft.iconSpecReference, + }; +} + +function createSameSourceCharacterAnimationDraft({ + sourceLayer, + canvasSize, + viewport, + sourceAnimationLayer, + mode, +}: SourceGenerationDialogDraftContext): Omit | null { + const animationSourceLayer = sourceAnimationLayer ?? sourceLayer; + const draft = createCharacterAnimationGenerationDialogDraft({ + canvasSize, + viewport, + layer: animationSourceLayer, + }); + if (!draft) { + return null; + } + return placeDraftBesideSourceLayer( + { + ...draft, + prompt: getSourceDraftPrompt({ sourceLayer, mode }), + sourceLayerId: animationSourceLayer.id, + composerOpen: true, + }, + sourceLayer, + ); +} + +export function createSameSourceGenerationDialogDraft({ + sourceLayer, + canvasSize, + viewport, + sourceDialog, + mode, + sourceAnimationLayer, +}: SourceGenerationDialogDraftContext): Omit | null { + const sourceMode = resolveGeneratedSourceDialogMode({ + sourceLayer, + sourceDialog, + }); + const prompt = getSourceDraftPrompt({ sourceLayer, mode }); + + if (sourceMode === 'quick-edit') { + return null; + } + + if (sourceMode === 'generate') { + return placeDraftBesideSourceLayer( + restoreSharedImageOptions( + { + ...createGenerateDialogDraft({ canvasSize, viewport }), + prompt, + sourceLayerId: sourceLayer.id, + generationReferences: + sourceDialog?.mode === 'generate' + ? (sourceDialog.generationReferences ?? []) + : [], + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); + } + + if (sourceMode === 'character-animation') { + return createSameSourceCharacterAnimationDraft({ + sourceLayer, + canvasSize, + viewport, + sourceDialog, + mode, + sourceAnimationLayer, + }); + } + + if (sourceMode === 'character') { + return placeDraftBesideSourceLayer( + restoreSharedImageOptions( + { + ...createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? DEFAULT_IMAGE_MODEL, + }), + prompt, + sourceLayerId: sourceLayer.id, + characterSpecReference: + sourceDialog?.mode === 'character' + ? sourceDialog.characterSpecReference + : null, + characterReferences: + sourceDialog?.mode === 'character' + ? (sourceDialog.characterReferences ?? []) + : [], + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); + } + + if (sourceMode === 'ui-design') { + return placeDraftBesideSourceLayer( + restoreSharedImageOptions( + { + ...createUiDesignGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2, + }), + prompt, + sourceLayerId: sourceLayer.id, + uiDesignSpecReference: + sourceDialog?.mode === 'ui-design' + ? sourceDialog.uiDesignSpecReference + : null, + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); + } + + if (sourceMode === 'publication') { + const workflowId = resolvePublicationWorkflowIdFromLayer( + sourceLayer, + sourceDialog, + ); + return placeDraftBesideSourceLayer( + restorePublicationGameInfoFromLayer( + { + ...createPublicationGenerationDialogDraft({ + canvasSize, + viewport, + workflowId, + }), + prompt, + sourceLayerId: sourceLayer.id, + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); + } + + if (sourceMode === 'icon') { + return placeDraftBesideSourceLayer( + restoreSharedImageOptions( + restoreIconDescriptionsFromLayer( + { + ...createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: + sourceDialog?.imageModel ?? sourceLayer.model ?? DEFAULT_IMAGE_MODEL, + }), + sourceLayerId: sourceLayer.id, + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); + } + + if (sourceMode === 'spec') { + const specType = resolveSpecTypeFromSourceLayer(sourceLayer, sourceDialog); + return placeDraftBesideSourceLayer( + restoreSpecValuesFromLayer( + { + ...createSpecDialogDraft({ canvasSize, viewport, specType }), + prompt, + sourceLayerId: sourceLayer.id, + specReference: + sourceDialog?.mode === 'spec' ? sourceDialog.specReference : null, + }, + sourceLayer, + specType, + sourceDialog, + ), + sourceLayer, + ); + } + + return null; +} + export function createUiDesignGenerationDialogDraft({ canvasSize, viewport, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index bc07c6f09..37c2dabfc 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -354,6 +354,72 @@ function GenerationWorkflowHarness({ > 打开用户快照改造 + + + +