修复画布生成器恢复串台

区分生成结果归属与派生输入关系,避免角色动作和快速编辑面板串台
补全角色动作、视频、音效、背景音乐与宣发素材的生成器恢复类型
统一清理剪切和素材删除后的关联生成状态,避免孤立占位
补充生成器恢复、宣发类型和删除清理回归测试并同步编辑器文档
This commit is contained in:
2026-07-17 21:23:01 +08:00
parent f5368c825f
commit 5468bb3212
14 changed files with 382 additions and 7 deletions
@@ -110,6 +110,10 @@
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效和背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -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 () => {
@@ -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<CanvasLayer[]>([]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(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(() => {
@@ -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(
@@ -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,
@@ -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 宣发素材',
},
});
@@ -442,6 +442,7 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
assetKind: 'publication-material',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${nextGeneratedIndex} 宣发素材`,
@@ -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<string | null>('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(<AssetCleanupHarness deletedAssets={[defaultAsset]} />);
const onDeleteLayerSideEffects = vi.fn();
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
/>,
);
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', () => {
@@ -34,6 +34,7 @@ type UseImageCanvasAssetLayerCleanupOptions = {
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
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,
],
);
}
@@ -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(
@@ -461,6 +461,23 @@ function GenerationWorkflowHarness({
>
写入普通图来源生成器
</button>
<button
type="button"
onClick={() =>
dialogs.openCanvasGenerationDialog({
mode: 'quick-edit',
prompt: '去除背景',
status: 'generating',
composerOpen: false,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
})
}
>
写入派生快速编辑生成器
</button>
<button
type="button"
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
@@ -528,6 +545,12 @@ function GenerationWorkflowHarness({
>
打开角色动画
</button>
<button
type="button"
onClick={() => workflow.openCharacterAnimationPanel(layers[0]!)}
>
对当前图层生成动作
</button>
<button
type="button"
onClick={() =>
@@ -1161,6 +1184,25 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('ignores a derived quick edit dialog when restoring the source generator', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(
screen.getByRole('button', { name: '写入普通图来源生成器' }),
);
fireEvent.click(
screen.getByRole('button', { name: '写入派生快速编辑生成器' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:-:placeholder',
);
expect(screen.getByTestId('dialog-image-options').textContent).toBe(
'generate:gpt-image-2:2:3:2K',
);
});
it('submits audio remodel with restored sound parameters and appends the new audio layer', async () => {
generateEditorSoundEffectMock.mockResolvedValueOnce({
audioSrc: 'data:audio/mpeg;base64,generated',
@@ -2019,6 +2061,92 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('image-context').textContent).toBe('-');
});
it('reopens the character generator when remodeling after opening character animation', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'character',
sourceType: 'generated',
}),
]}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '对当前图层生成动作' }),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'character-animation:idle:open:-:placeholder',
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:-:placeholder',
);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:character-animation:closed|generation-dialog-2:character:open',
);
});
it('reopens character animation remodeling from an action result layer', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
id: 'layer-action',
resourceId: 'resource-action',
title: '角色动作',
mediaType: 'image-sequence',
assetKind: 'character-animation',
sourceType: 'generated',
sourceResourceId: 'resource-character',
}),
createLayer({
id: 'layer-character',
resourceId: 'resource-character',
title: '角色形象',
assetKind: 'character',
sourceType: 'generated',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'character-animation:idle:open:-:placeholder',
);
expect(screen.getByTestId('character-animation').textContent).toBe(
'layer-character:idle',
);
});
it('warns when an action result has lost its source character layer', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
title: '角色动作',
mediaType: 'image-sequence',
assetKind: 'character-animation',
sourceType: 'generated',
sourceResourceId: 'missing-character-resource',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'未找到角色动作关联的原角色图层,无法改造。',
);
});
it('keeps character animation asset names synchronized with the dialog', () => {
render(<GenerationWorkflowHarness />);
@@ -242,10 +242,59 @@ function findSourceGenerationDialog(
.find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
(!dialog.generatedLayerId && dialog.sourceLayerId === sourceLayer.id),
// sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。
(!dialog.generatedLayerId &&
dialog.sourceLayerId === sourceLayer.id &&
isGenerationDialogModeCompatibleWithSourceLayer(
dialog.mode,
sourceLayer,
)),
);
}
function isGenerationDialogModeCompatibleWithSourceLayer(
mode: CanvasGenerationDialogState['mode'],
sourceLayer: CanvasLayer,
) {
if (mode === 'quick-edit') {
return false;
}
if (sourceLayer.assetKind === 'character') {
return mode === 'character';
}
if (sourceLayer.assetKind === 'character-animation') {
return mode === 'character-animation';
}
if (sourceLayer.assetKind === 'ui-design') {
return mode === 'ui-design';
}
if (sourceLayer.assetKind === 'publication-material') {
return mode === 'publication';
}
if (
sourceLayer.assetKind === 'icon' ||
sourceLayer.assetKind === 'icon-spritesheet'
) {
return mode === 'icon';
}
if (
sourceLayer.assetKind === 'spec' ||
sourceLayer.assetKind === 'icon-spec'
) {
return mode === 'spec';
}
if (sourceLayer.assetKind === 'video') {
return mode === 'video';
}
if (sourceLayer.assetKind === 'sound-effect') {
return mode === 'audio-sound-effect';
}
if (sourceLayer.assetKind === 'background-music') {
return mode === 'audio-background-music';
}
return true;
}
function findSourceAnimationLayer(
layers: CanvasLayer[],
sourceLayer: CanvasLayer,
@@ -1236,19 +1285,24 @@ export function useImageCanvasGenerationWorkflow({
}
if (
sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image'
sourceLayer.mediaType === 'image' ||
sourceLayer.assetKind === 'character-animation'
) {
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
sourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode: 'redraw',
sourceAnimationLayer: findSourceAnimationLayer(layers, sourceLayer),
sourceAnimationLayer,
});
if (sameSourceDraft) {
openPlacedCanvasGenerationDialog(sameSourceDraft);
@@ -1257,6 +1311,10 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
return;
}
if (sourceLayer.assetKind === 'character-animation') {
setGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
return;
}
const redrawPanel = createRedrawPanelDraft(sourceLayer, {
imageModel: sourceDialog?.imageModel,
aspectRatio: sourceDialog?.aspectRatio,
@@ -427,7 +427,12 @@ describe('useImageCanvasLayerCommands', () => {
it('copies, cuts, and pastes context layers with history and selection updates', async () => {
const write = installImageClipboardMock();
render(<LayerCommandsHarness />);
const onDeleteLayerSideEffects = vi.fn();
render(
<LayerCommandsHarness
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '复制' }));
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
@@ -459,6 +464,7 @@ describe('useImageCanvasLayerCommands', () => {
expect(screen.getByTestId('layers').textContent).not.toContain('third');
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(screen.getByTestId('image-context-closed').textContent).toBe('3');
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third');
expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan(
1,
);
@@ -239,6 +239,7 @@ export function useImageCanvasLayerCommands({
? null
: currentLayer,
);
targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
}
closeContextMenus();
},
@@ -247,6 +248,7 @@ export function useImageCanvasLayerCommands({
closeContextMenus,
getContextTargetLayerIds,
layers,
onDeleteLayerSideEffects,
selectSingleLayer,
setLayers,
setMetadataLayer,