抽出编辑器生成提交流水线

新增 useImageCanvasGenerationSubmissionWorkflow 承载生成提交和结果落图副作用

补充生成提交流水线 hook 单测

精简 useImageCanvasGenerationWorkflow 的提交编排逻辑

更新 TRACKING.md 记录第四十三执行批次验证
This commit is contained in:
2026-06-17 19:54:41 +08:00
parent 7f573486bc
commit 489b0a7743
4 changed files with 1166 additions and 443 deletions

View File

@@ -0,0 +1,578 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
CharacterAnimationPanelState,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock,
}));
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
};
});
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'https://assets.example.test/source.png',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
function createGenerated(overrides = {}) {
return {
imageSrc: 'data:image/png;base64,generated',
width: 1024,
height: 1024,
sourceType: 'generated' as const,
prompt: '生成提示词',
actualPrompt: '生成提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-generated',
...overrides,
};
}
function createIconResult(name: string, imageSrc: string) {
return {
name,
imageSrc,
width: 128,
height: 128,
};
}
function SubmissionWorkflowHarness({
initialDialog = null,
initialQuickEditPanel = null,
initialCharacterAnimationPanel = null,
initialLayers = [createLayer()],
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
initialLayers?: CanvasLayer[];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(initialQuickEditPanel);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(
initialCharacterAnimationPanel,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersMockRef = useRef(vi.fn());
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
const sourceLayer = layers[0] ?? null;
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
: null;
const characterAnimationSourceLayer = characterAnimationPanel
? (layers.find(
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
) ?? null)
: null;
const workflow = useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: (imageModel) => {
rememberedImageModelRef.current = imageModel;
},
});
const activeDialog = dialogs.generateDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
: null;
return (
<div>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
<span data-testid="layers">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`,
)
.join('|')}
</span>
<span data-testid="dialog">
{activeDialog
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="quick-edit">
{quickEditPanel
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="character-animation">
{characterAnimationPanel
? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}`
: '-'}
</span>
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
<span data-testid="remembered-model">
{rememberedImageModelRef.current ?? '-'}
</span>
<button
type="button"
onClick={() => {
if (initialDialog) {
dialogs.setGenerateDialog(initialDialog);
}
}}
>
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
}
}}
>
</button>
<button
type="button"
onClick={() =>
setQuickEditPanel((currentPanel) =>
currentPanel ? { ...currentPanel, prompt: ' 快速修图 ' } : null,
)
}
>
</button>
<button
type="button"
onClick={() => void workflow.submitQuickEdit()}
>
</button>
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitImageGeneration(activeDialog);
}
}}
>
</button>
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitIconSpritesheetGeneration(activeDialog);
}
}}
>
</button>
<button
type="button"
onClick={() => void workflow.submitCharacterAnimation()}
>
</button>
<button
type="button"
onClick={() => {
if (activeCanvasDialog) {
dialogs.updateCanvasGenerationDialogById(
activeCanvasDialog.id,
(dialog) => ({
...dialog,
placeholder: dialog.placeholder
? { ...dialog.placeholder, x: 500, y: 400 }
: dialog.placeholder,
}),
);
}
}}
>
</button>
</div>
);
}
describe('useImageCanvasGenerationSubmissionWorkflow', () => {
beforeEach(() => {
resolveEditorImageReferenceDataUrlMock.mockReset();
editEditorImageMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
resolveEditorImageReferenceDataUrlMock.mockImplementation(
async (src: string) => `data:image/png;base64,${src.split('/').pop()}`,
);
});
it('submits quick edits, clears the panel, and fits the source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<SubmissionWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:generating:快速修图:-',
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '快速修图',
size: '1024x768',
kind: 'quick-edit',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source.png'],
});
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected').textContent).toBe(
'layer-quick-edit-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:failed:快速编辑图片:修图失败',
);
});
});
it('submits edit dialogs beside the source and clears the modal state', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '修改当前图片' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: 'layer-source',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'edit:generating:open:-:-:-',
);
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith({
prompt: '修改当前图片',
sourceImageSrc: 'data:image/png;base64,source.png',
});
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-edit-1:源图 修改结果:resource-source:-',
);
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('uses the latest moved canvas placeholder when a generation completes', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 512, height: 512 }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-1',
mode: 'generate',
prompt: '占位生成',
status: 'idle',
composerOpen: true,
placeholder: {
x: 100,
y: 100,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '移动占位' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:生成图片 1:-:-',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:-:-',
);
});
it('validates icon submission before calling the API', async () => {
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
iconDescriptions: ['返回按钮'],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'请选择图标素材规范',
);
});
});
it('submits icon spritesheets, trims descriptions, and remembers the model', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
prompt: '图标素材',
actualPrompt: '图标素材',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
iconImageSrcs: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
createIconResult('设置按钮', 'data:image/png;base64,setting'),
],
});
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
},
iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-1:返回按钮:-:icon',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-2:设置按钮:-:icon',
);
expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1');
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
});
it('moves character animation panels from generating to completed', async () => {
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
previewSrc: 'data:image/png;base64,preview',
taskId: 'animation-task',
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 跑步 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:generating:跑步:-:-',
);
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
promptText: '跑步',
model: 'seedance2.0',
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:completed:跑步:-:result',
);
});
});
it('uses a fallback error message for failed character animation submissions', async () => {
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ id: 'character-layer' })]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:failed:-:生成角色动画失败:-',
);
});
});
});

View File

@@ -0,0 +1,508 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useMemo,
} from 'react';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
editEditorImage,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
buildEditGenerationInputs,
isCanvasGenerationDialog,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
} from './ImageCanvasGenerationSubmissionModel';
type CanvasSize = { width: number; height: number };
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type GenerationSubmissionWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
viewport: CanvasViewport;
layerCounterRef: MutableRefObject<number>;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
setCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
removeCanvasGenerationDialogById: (dialogId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
};
export function useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedResultLayer = useCallback(
(
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
options: {
sourceLayer?: CanvasLayer;
frame?: GenerateDialogState['placeholder'];
assetKind?: CanvasLayer['assetKind'];
title?: string;
dialogId?: string;
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
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,
});
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (options.sourceLayer) {
setGenerateDialog(null);
setActiveTool('select');
} else if (options.dialogId) {
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
? null
: {
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined,
},
);
}
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]);
}
},
[
appendCanvasLayersWithResources,
canvasSize,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setGenerateDialog,
updateCanvasGenerationDialogById,
viewport,
],
);
const addQuickEditResultLayer = useCallback(
(
generated: Parameters<typeof createQuickEditResultLayer>[0]['generated'],
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createQuickEditResultLayer({
generated,
generatedIndex,
sourceLayer,
generationInputs,
});
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
setQuickEditPanel(null);
setActiveTool('select');
fitLayers([sourceLayer, nextLayer]);
},
[
appendCanvasLayersWithResources,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
],
);
const addIconSpritesheetResultLayers = useCallback(
(
generated: Parameters<
typeof createIconSpritesheetResultLayers
>[0]['generated'],
iconResults: Parameters<
typeof createIconSpritesheetResultLayers
>[0]['iconResults'],
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
generated,
iconResults,
startIndex,
canvasSize,
viewport,
generationInputs,
frame,
});
if (!nextLayers.length) {
return;
}
layerCounterRef.current += nextLayers.length;
appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
removeCanvasGenerationDialogById(dialogId);
}
setActiveTool('select');
},
[
appendCanvasLayersWithResources,
canvasSize,
layerCounterRef,
removeCanvasGenerationDialogById,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
viewport,
],
);
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);
if (!submissionPlan.ok) {
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 generated = await generateEditorIconSpritesheet(
submissionPlan.input,
);
rememberImageModel(submissionPlan.rememberImageModel);
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
submissionPlan.generationInputs,
getGeneratingDialogPlaceholder(dialog),
canvasDialog.id,
);
} catch (error) {
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
},
[
addIconSpritesheetResultLayers,
getGeneratingDialogPlaceholder,
rememberImageModel,
updateCanvasGenerationDialogById,
],
);
const submitQuickEdit = useCallback(async () => {
if (!quickEditPanel || !quickEditSourceLayer) {
return;
}
const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片';
setQuickEditPanel({
...quickEditPanel,
prompt: normalizedPrompt,
status: 'generating',
errorMessage: undefined,
});
try {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.src,
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: quickEditPanel.model,
referenceImageSrcs: [referenceImageSrc],
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
buildEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
),
);
} catch (error) {
setQuickEditPanel({
...quickEditPanel,
prompt: normalizedPrompt,
status: 'failed',
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
}, [
addQuickEditResultLayer,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
]);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片');
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: dialog.mode === 'edit',
});
}
try {
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.src,
);
const generated = await editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
});
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
generationInputs: submissionPlan.generationInputs,
});
} else {
const generated = await generateEditorImage(submissionPlan.input);
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
}
} catch (error) {
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,
getGeneratingDialogPlaceholder,
layerCounterRef,
layers,
rememberImageModel,
setGenerateDialog,
updateCanvasGenerationDialogById,
],
);
const submitCharacterAnimation = useCallback(async () => {
if (!characterAnimationPanel || !characterAnimationSourceLayer) {
return;
}
const submissionPlan = buildCharacterAnimationSubmissionPlan({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
});
const nextPanel = {
...characterAnimationPanel,
promptText: submissionPlan.promptText,
status: 'generating' as const,
errorMessage: undefined,
result: undefined,
};
setCharacterAnimationPanel(nextPanel);
try {
const result = await generateEditorCharacterAnimation(
submissionPlan.input,
);
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
status: 'completed',
result,
}
: currentPanel,
);
} catch (error) {
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '生成角色动画失败',
}
: currentPanel,
);
}
}, [
characterAnimationPanel,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
]);
return useMemo(
() => ({
submitIconSpritesheetGeneration,
submitQuickEdit,
submitImageGeneration,
submitCharacterAnimation,
}),
[
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
],
);
}

View File

@@ -7,19 +7,8 @@ import {
useState,
} from 'react';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
editEditorImage,
type EditorIconSpritesheetGenerationResult,
type EditorIconSpritesheetIconResult,
type EditorImageGenerationResult,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -50,25 +39,13 @@ import {
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
buildEditGenerationInputs,
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
calculateCharacterAnimationPrice,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
isCanvasGenerationDialog,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
} from './ImageCanvasGenerationSubmissionModel';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
type CanvasSize = { width: number; height: number };
@@ -303,145 +280,6 @@ export function useImageCanvasGenerationWorkflow({
],
);
const addGeneratedResultLayer = useCallback(
(
generated: EditorImageGenerationResult,
options: {
sourceLayer?: CanvasLayer;
frame?: GenerateDialogState['placeholder'];
assetKind?: CanvasLayer['assetKind'];
title?: string;
dialogId?: string;
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
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,
});
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (options.sourceLayer) {
setGenerateDialog(null);
setActiveTool('select');
} else if (options.dialogId) {
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
? null
: {
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined,
},
);
}
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]);
}
},
[
appendCanvasLayersWithResources,
canvasSize,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setGenerateDialog,
updateCanvasGenerationDialogById,
viewport,
],
);
const addQuickEditResultLayer = useCallback(
(
generated: EditorImageGenerationResult,
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createQuickEditResultLayer({
generated,
generatedIndex,
sourceLayer,
generationInputs,
});
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
setQuickEditPanel(null);
setActiveTool('select');
fitLayers([sourceLayer, nextLayer]);
},
[
appendCanvasLayersWithResources,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
],
);
const addIconSpritesheetResultLayers = useCallback(
(
generated: EditorIconSpritesheetGenerationResult,
iconResults: EditorIconSpritesheetIconResult[],
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
generated,
iconResults,
startIndex,
canvasSize,
viewport,
generationInputs,
frame,
});
if (!nextLayers.length) {
return;
}
layerCounterRef.current += nextLayers.length;
appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
removeCanvasGenerationDialogById(dialogId);
}
setActiveTool('select');
},
[
appendCanvasLayersWithResources,
canvasSize,
layerCounterRef,
removeCanvasGenerationDialogById,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
viewport,
],
);
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
@@ -493,197 +331,34 @@ export function useImageCanvasGenerationWorkflow({
setGenerateDialog(appendIconDescriptionToDialog);
}, [setGenerateDialog]);
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);
if (!submissionPlan.ok) {
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 generated = await generateEditorIconSpritesheet(
submissionPlan.input,
);
setLastImageModel(submissionPlan.rememberImageModel);
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
submissionPlan.generationInputs,
getGeneratingDialogPlaceholder(dialog),
canvasDialog.id,
);
} catch (error) {
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
},
[
addIconSpritesheetResultLayers,
getGeneratingDialogPlaceholder,
updateCanvasGenerationDialogById,
],
);
const submitQuickEdit = useCallback(async () => {
if (!quickEditPanel || !quickEditSourceLayer) {
return;
}
const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片';
setQuickEditPanel({
...quickEditPanel,
prompt: normalizedPrompt,
status: 'generating',
errorMessage: undefined,
});
try {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.src,
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: quickEditPanel.model,
referenceImageSrcs: [referenceImageSrc],
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
buildEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
),
);
} catch (error) {
setQuickEditPanel({
...quickEditPanel,
prompt: normalizedPrompt,
status: 'failed',
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
}, [addQuickEditResultLayer, quickEditPanel, quickEditSourceLayer]);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片');
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: dialog.mode === 'edit',
});
}
try {
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.src,
);
const generated = await editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
});
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
generationInputs: submissionPlan.generationInputs,
});
} else {
const generated = await generateEditorImage(submissionPlan.input);
if (submissionPlan.rememberImageModel) {
setLastImageModel(submissionPlan.rememberImageModel);
}
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
}
} catch (error) {
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,
getGeneratingDialogPlaceholder,
layerCounterRef,
layers,
setGenerateDialog,
updateCanvasGenerationDialogById,
],
);
const generationSubmissionWorkflow = useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: setLastImageModel,
});
const {
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
} = generationSubmissionWorkflow;
const updateSpecFormValue = useCallback(
(key: keyof SpecFormValues, value: string) => {
@@ -703,52 +378,6 @@ export function useImageCanvasGenerationWorkflow({
[],
);
const submitCharacterAnimation = useCallback(async () => {
if (!characterAnimationPanel || !characterAnimationSourceLayer) {
return;
}
const submissionPlan = buildCharacterAnimationSubmissionPlan({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
});
const nextPanel = {
...characterAnimationPanel,
promptText: submissionPlan.promptText,
status: 'generating' as const,
errorMessage: undefined,
result: undefined,
};
setCharacterAnimationPanel(nextPanel);
try {
const result = await generateEditorCharacterAnimation(
submissionPlan.input,
);
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
status: 'completed',
result,
}
: currentPanel,
);
} catch (error) {
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '生成角色动画失败',
}
: currentPanel,
);
}
}, [characterAnimationPanel, characterAnimationSourceLayer]);
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
hideGeneratedLayerComposerAfterBlur(currentDialog),