抽出编辑器生成提交流水线
新增 useImageCanvasGenerationSubmissionWorkflow 承载生成提交和结果落图副作用 补充生成提交流水线 hook 单测 精简 useImageCanvasGenerationWorkflow 的提交编排逻辑 更新 TRACKING.md 记录第四十三执行批次验证
This commit is contained in:
@@ -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:-:生成角色动画失败:-',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user