diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 6b5d6a55c..6fd784f9f 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -110,6 +110,10 @@ - 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。 - 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。 - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 +- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 +- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 +- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效和背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器。 +- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 ## 画布保存 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index da6398880..cb64f0f54 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3332,6 +3332,17 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByText('1/48')).toBeTruthy(); expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull(); expect(screen.getByText('动作')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '选择角色动作' })); + fireEvent.click(screen.getByRole('button', { name: '改造' })); + + const remodelPanel = screen.getByRole('dialog', { + name: '角色动画生成面板', + }); + expect( + (within(remodelPanel).getByLabelText('动画描述') as HTMLTextAreaElement) + .value, + ).toBe(precisePrompt); }); it('extracts UI design assets from the floating toolbar and keeps the spritesheet on canvas', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8a2ce7ef1..367cae2e7 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -408,6 +408,9 @@ export function ImageCanvasEditorView({ const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); + const clearDeletedLayerGenerationStateRef = useRef< + (targetLayerId: string) => void + >(() => {}); const suppressAssetClickRef = useRef(false); const [layers, setLayers] = useState([]); const [selectedLayerId, setSelectedLayerId] = useState(null); @@ -636,6 +639,8 @@ export function ImageCanvasEditorView({ setLayers, setSelectedLayerId, setSelectedLayerIds, + onDeleteLayerSideEffects: (layerId) => + clearDeletedLayerGenerationStateRef.current(layerId), }); const { assetFolders, @@ -1402,6 +1407,8 @@ export function ImageCanvasEditorView({ setContextMenu(null); }; setQuickEditPanelRef.current = setQuickEditPanel; + clearDeletedLayerGenerationStateRef.current = + clearDeletedLayerGenerationState; appendUiAssetExtractionReferencesRef.current = appendUiAssetExtractionReferences; useEffect(() => { diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index b2300817f..65fb3c79f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -672,6 +672,75 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('restores video and audio generators from media layer metadata', () => { + const context = { + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }; + + expect( + createLayerGenerationDialogDraft({ + ...context, + sourceLayer: createLayer({ + title: '技能演示', + mediaType: 'video', + assetKind: 'video', + sourceType: 'generated', + generationInputs: { + fields: [{ title: '视频描述', value: '火球飞行动画' }], + references: [], + }, + }), + }), + ).toMatchObject({ + mode: 'video', + prompt: '火球飞行动画', + generatedLayerId: 'layer-source', + }); + + expect( + createLayerGenerationDialogDraft({ + ...context, + sourceLayer: createLayer({ + title: '金币音效', + mediaType: 'audio', + assetKind: 'sound-effect', + sourceType: 'generated', + generationInputs: { + fields: [{ title: 'prompt', value: '金币弹出声' }], + references: [], + }, + }), + }), + ).toMatchObject({ + mode: 'audio-sound-effect', + prompt: '金币弹出声', + generatedLayerId: 'layer-source', + }); + + expect( + createLayerGenerationDialogDraft({ + ...context, + sourceLayer: createLayer({ + title: '背景音乐', + mediaType: 'audio', + assetKind: 'background-music', + sourceType: 'generated', + generationInputs: { + fields: [ + { title: 'gpt_description_prompt', value: '轻松森林音乐' }, + ], + references: [], + }, + }), + }), + ).toMatchObject({ + mode: 'audio-background-music', + prompt: '轻松森林音乐', + generatedLayerId: 'layer-source', + }); + }); + it('inherits and normalizes quick edit model parameters from the source', () => { expect( createQuickEditPanelDraft( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 7e7339a8f..6d8a724db 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -650,6 +650,21 @@ function resolveGeneratedSourceDialogMode({ if (sourceLayer.assetKind === 'character-animation') { return 'character-animation'; } + if ( + sourceLayer.assetKind === 'video' || + sourceLayer.mediaType === 'video' + ) { + return 'video'; + } + if (sourceLayer.assetKind === 'sound-effect') { + return 'audio-sound-effect'; + } + if (sourceLayer.assetKind === 'background-music') { + return 'audio-background-music'; + } + if (sourceLayer.mediaType === 'audio') { + return resolveAudioRedrawMode(sourceLayer); + } if (sourceLayer.sourceType === 'generated') { return 'generate'; } @@ -963,6 +978,51 @@ export function createSameSourceGenerationDialogDraft({ ); } + if (sourceMode === 'video') { + const draft = createVideoRedrawGenerationDialogDraft(sourceLayer); + if (!draft) { + return null; + } + return { + ...draft, + prompt, + videoModel: sourceDialog?.videoModel ?? draft.videoModel, + videoAspectRatio: + sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio, + videoResolution: + sourceDialog?.videoResolution ?? draft.videoResolution, + videoDurationSeconds: + sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds, + videoMode: sourceDialog?.videoMode ?? draft.videoMode, + videoSound: sourceDialog?.videoSound ?? draft.videoSound, + videoWebSearchEnabled: + sourceDialog?.videoWebSearchEnabled ?? draft.videoWebSearchEnabled, + generationReferences: + sourceDialog?.mode === 'video' + ? (sourceDialog.generationReferences ?? draft.generationReferences) + : draft.generationReferences, + }; + } + + if ( + sourceMode === 'audio-sound-effect' || + sourceMode === 'audio-background-music' + ) { + const draft = createAudioRedrawGenerationDialogDraft(sourceLayer); + if (!draft) { + return null; + } + return { + ...draft, + prompt, + soundModel: sourceDialog?.soundModel ?? draft.soundModel, + soundDurationSeconds: + sourceDialog?.soundDurationSeconds ?? draft.soundDurationSeconds, + makeInstrumental: + sourceDialog?.makeInstrumental ?? draft.makeInstrumental, + }; + } + if (sourceMode === 'character-animation') { return createSameSourceCharacterAnimationDraft({ sourceLayer, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index cab953c19..fd46c6e25 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -467,6 +467,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { referenceImageSrcs: ['data:image/png;base64,ref'], }, result: { + assetKind: 'publication-material', title: '10 宣发素材', generationInputs: { fields: [ @@ -521,6 +522,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { imageSize: '1K', }, result: { + assetKind: 'publication-material', title: '11 宣发素材', }, }); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 2b7fa8485..2dae49c16 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -442,6 +442,7 @@ export function buildImageGenerationSubmissionPlan({ : {}), }, result: { + assetKind: 'publication-material', title: resolveGenerationAssetLabel( dialog.assetLabel, `${nextGeneratedIndex} 宣发素材`, diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx index 020386c18..132c57a0d 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -157,9 +157,11 @@ function AssetCleanupHarness({ sourceAssetId: 'asset-2', }), ], + onDeleteLayerSideEffects = vi.fn(), }: { deletedAssets?: EditorAsset[]; initialLayers?: CanvasLayer[]; + onDeleteLayerSideEffects?: (layerId: string) => void; }) { const [layers, setLayers] = useState(initialLayers); const [selectedLayerId, setSelectedLayerId] = useState('linked'); @@ -169,6 +171,7 @@ function AssetCleanupHarness({ setLayers, setSelectedLayerId, setSelectedLayerIds, + onDeleteLayerSideEffects, }); return ( @@ -245,7 +248,13 @@ describe('useImageCanvasAssetCanvasBridge', () => { }); it('removes canvas layers linked to deleted assets and keeps unrelated selection', () => { - render(); + const onDeleteLayerSideEffects = vi.fn(); + render( + , + ); act(() => { screen.getByRole('button', { name: '清理素材' }).click(); @@ -254,6 +263,7 @@ describe('useImageCanvasAssetCanvasBridge', () => { expect(screen.getByTestId('layers').textContent).toBe('kept'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('selected-many').textContent).toBe('kept'); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('linked'); }); it('removes canvas layers linked by the asset source resource id', () => { diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index 73148f038..1e4753de9 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -34,6 +34,7 @@ type UseImageCanvasAssetLayerCleanupOptions = { setLayers: Dispatch>; setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; + onDeleteLayerSideEffects?: (layerId: string) => void; }; type UseImageCanvasAssetCanvasBridgeOptions = { @@ -67,12 +68,18 @@ export function useImageCanvasAssetLayerCleanup({ setLayers, setSelectedLayerId, setSelectedLayerIds, + onDeleteLayerSideEffects, }: UseImageCanvasAssetLayerCleanupOptions) { return useCallback( (deletedAssets: EditorAsset[]) => { if (!deletedAssets.length) { return; } + const deletedLayerIds = layers + .filter((layer) => + deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ) + .map((layer) => layer.id); setLayers((currentLayers) => currentLayers.filter( (layer) => @@ -100,8 +107,17 @@ export function useImageCanvasAssetLayerCleanup({ ? null : currentId; }); + deletedLayerIds.forEach((layerId) => + onDeleteLayerSideEffects?.(layerId), + ); }, - [layers, setLayers, setSelectedLayerId, setSelectedLayerIds], + [ + layers, + onDeleteLayerSideEffects, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + ], ); } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 0e158971c..4c321ed3c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2058,6 +2058,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', + assetKind: 'publication-material', model: 'gpt-image-2', aspectRatio: '9:16', imageSize: '1K', @@ -2073,7 +2074,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(prompt).toContain('不是五图合集或拼贴'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-generated-1:1 宣发素材:-:-', + 'layer-generated-1:1 宣发素材:-:publication-material:-', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 8ca83fe19..f226754fe 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -461,6 +461,23 @@ function GenerationWorkflowHarness({ > 写入普通图来源生成器 + +