抽出编辑器生成对话状态模型

新增 ImageCanvasGenerationDialogModel 承载生成面板草稿和引用选择规则

补充生成对话状态模型单测

精简 useImageCanvasGenerationWorkflow 中的面板状态构造

更新 TRACKING.md 记录第四十一阶段验证
This commit is contained in:
2026-06-17 19:12:11 +08:00
parent c22b7803cc
commit 4e4edc285b
4 changed files with 780 additions and 298 deletions

View File

@@ -0,0 +1,317 @@
import { describe, expect, it } from 'vitest';
import type {
CanvasLayer,
CharacterAnimationPanelState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendIconDescriptionToDialog,
assignCharacterSpecReference,
assignIconSpecReference,
closeGenerateComposerDialog,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createQuickEditPanelDraft,
createSpecDialogDraft,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import { ICON_DESCRIPTION_LIMIT } from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
describe('ImageCanvasGenerationDialogModel', () => {
it('creates centered drafts for image and spec generation dialogs', () => {
const canvasSize = { width: 1000, height: 800 };
const viewport = { x: 100, y: 40, scale: 2 };
expect(createGenerateDialogDraft({ canvasSize, viewport })).toMatchObject({
mode: 'generate',
status: 'idle',
composerOpen: true,
placeholder: {
x: -10,
y: -30,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }),
).toMatchObject({
mode: 'spec',
specType: 'icon',
specValues: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
},
placeholder: {
x: -80,
y: 22.5,
width: 560,
height: 315,
},
});
});
it('creates character and icon generation drafts with model dimensions', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'gpt-image-2',
}),
).toMatchObject({
mode: 'character',
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
characterSpecReference: null,
characterReferences: [],
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
},
});
expect(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'unknown-model',
}),
).toMatchObject({
mode: 'icon',
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
iconSpecReference: null,
iconDescriptions: [
'返回按钮',
'设置按钮',
'下一关按钮',
'提示按钮',
'原图按钮',
'冻结按钮',
],
placeholder: {
x: 300,
y: 180,
width: 360,
height: 360,
},
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '原图提示',
model: 'gpt-image-2',
assetKind: 'character',
});
expect(createEditDialogDraft(sourceLayer)).toEqual({
mode: 'edit',
prompt: '原图提示,在保持主体结构的基础上优化画面细节',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
});
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('resets failed character and icon dialogs when picking references', () => {
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '请选择角色规范',
characterReferences: [],
};
const sourceLayer = createLayer({ title: '参考图' });
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: {
id: 'canvas-layer-source',
label: '参考图',
src: 'data:image/png;base64,source',
},
}),
);
expect(
appendCharacterReference(characterDialog, sourceLayer),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
characterReferences: [{ label: '参考图' }],
});
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'failed',
errorMessage: '请选择图标素材规范',
};
expect(
assignIconSpecReference(
iconDialog,
createLayer({ assetKind: 'icon-spec' }),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
iconSpecReference: { id: 'canvas-layer-source' },
});
expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog);
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
status: 'failed',
errorMessage: '生成失败',
specType: 'custom',
specValues: {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
};
expect(
updateSpecFormDialogValue(specDialog, 'customPrompt', '新规范'),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
specValues: expect.objectContaining({
customPrompt: '新规范',
bodyRatio: '3',
}),
}),
);
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'failed',
errorMessage: '描述错误',
iconDescriptions: ['旧描述'],
};
expect(updateIconDescriptionInDialog(iconDialog, 0, '新描述')).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
iconDescriptions: ['新描述'],
}),
);
expect(appendIconDescriptionToDialog(iconDialog)).toEqual(
expect.objectContaining({
iconDescriptions: ['旧描述', ''],
}),
);
expect(
appendIconDescriptionToDialog({
...iconDialog,
iconDescriptions: Array.from(
{ length: ICON_DESCRIPTION_LIMIT },
(_, index) => `图标${index}`,
),
}),
).toEqual(
expect.objectContaining({
iconDescriptions: expect.arrayContaining(['图标0']),
}),
);
});
it('updates character animation duration and composer visibility', () => {
const failedPanel: CharacterAnimationPanelState = {
sourceLayerId: 'layer-character',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'failed',
errorMessage: '失败',
};
expect(updateCharacterAnimationDurationPanel(failedPanel, '48')).toEqual({
...failedPanel,
frameCount: 48,
durationSeconds: 6,
status: 'idle',
errorMessage: undefined,
});
expect(updateCharacterAnimationDurationPanel(failedPanel, '999')).toBe(
failedPanel,
);
const generateDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
};
expect(hideGeneratedLayerComposerAfterBlur(generateDialog)).toEqual({
...generateDialog,
composerOpen: false,
});
expect(closeGenerateComposerDialog(generateDialog)).toEqual({
...generateDialog,
composerOpen: false,
});
});
});

View File

@@ -0,0 +1,369 @@
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
function getViewportWorldCenter({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}) {
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
return {
x: (canvasSize.width / 2 - viewport.x) / safeScale,
y: (canvasSize.height / 2 - viewport.y) / safeScale,
};
}
function resetFailedGenerationDialog(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function resolveImageDimensionDefaults(imageModel: string) {
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
return {
aspectRatio: dimensionOptions.aspectRatios[0],
imageSize:
dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0],
};
}
export function createGenerateDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const placeholderWidth = 420;
const placeholderHeight = 420;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
placeholder: {
x: worldCenter.x - placeholderWidth / 2,
y: worldCenter.y - placeholderHeight / 2,
width: placeholderWidth,
height: placeholderHeight,
originalWidth: 2048,
originalHeight: 2048,
},
};
}
export function createSpecDialogDraft({
canvasSize,
viewport,
specType,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
specType: SpecGenerationType;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType,
specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] },
placeholder: {
x: worldCenter.x - SPEC_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - SPEC_FRAME_DISPLAY_SIZE.height / 2,
width: SPEC_FRAME_DISPLAY_SIZE.width,
height: SPEC_FRAME_DISPLAY_SIZE.height,
originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width,
originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
return {
mode: 'character',
prompt: '',
status: 'idle',
composerOpen: true,
characterSpecReference: null,
characterReferences: [],
imageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2,
width: CHARACTER_FRAME_DISPLAY_SIZE.width,
height: CHARACTER_FRAME_DISPLAY_SIZE.height,
originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width,
originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
return {
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
iconSpecReference: null,
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
imageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createEditDialogDraft(
sourceLayer: CanvasLayer,
): GenerateDialogState {
return {
mode: 'edit',
prompt: sourceLayer.prompt
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
: '',
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
};
}
export function createQuickEditPanelDraft(
sourceLayer: CanvasLayer,
): QuickEditPanelState {
return {
sourceLayerId: sourceLayer.id,
prompt: '',
size: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
status: 'idle',
};
}
export function createCharacterAnimationPanelDraft(
layer: CanvasLayer,
): CharacterAnimationPanelState | null {
if (layer.assetKind !== 'character') {
return null;
}
return {
sourceLayerId: layer.id,
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
};
}
export function assignCharacterSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character'
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function appendCharacterReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (layer.assetKind !== 'icon-spec') {
return dialog;
}
return dialog?.mode === 'icon'
? {
...resetFailedGenerationDialog(dialog),
iconSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function updateSpecFormDialogValue(
dialog: GenerateDialogState | null,
key: keyof SpecFormValues,
value: string,
): GenerateDialogState | null {
if (dialog?.mode !== 'spec') {
return dialog;
}
const specType = dialog.specType ?? 'custom';
return {
...resetFailedGenerationDialog(dialog),
specValues: {
...DEFAULT_SPEC_FORM_VALUES[specType],
...dialog.specValues,
[key]: value,
},
};
}
export function updateIconDescriptionInDialog(
dialog: GenerateDialogState | null,
index: number,
value: string,
): GenerateDialogState | null {
return dialog?.mode === 'icon'
? {
...resetFailedGenerationDialog(dialog),
iconDescriptions: (
dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS
).map((description, descriptionIndex) =>
descriptionIndex === index ? value : description,
),
}
: dialog;
}
export function appendIconDescriptionToDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
if (dialog?.mode !== 'icon') {
return dialog;
}
const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
if (descriptions.length >= ICON_DESCRIPTION_LIMIT) {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
iconDescriptions: [...descriptions, ''],
};
}
export function updateCharacterAnimationDurationPanel(
panel: CharacterAnimationPanelState | null,
frameCountValue: string,
): CharacterAnimationPanelState | null {
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
(item) => String(item.frameCount) === frameCountValue,
);
if (!option || !panel) {
return panel;
}
return {
...panel,
frameCount: option.frameCount,
durationSeconds: option.durationSeconds,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function hideGeneratedLayerComposerAfterBlur(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return (dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon') &&
dialog.status !== 'generating'
? {
...dialog,
composerOpen: false,
}
: dialog;
}
export function closeGenerateComposerDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return dialog?.mode === 'generate'
? {
...dialog,
composerOpen: false,
}
: dialog;
}

View File

@@ -1,54 +1,22 @@
import {
useCallback,
useMemo,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useMemo,
useState,
} from 'react';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
editEditorImage,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
type EditorIconSpritesheetGenerationResult,
type EditorIconSpritesheetIconResult,
type EditorImageGenerationResult,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
} from '../../services/image-editor/editorProjectClient';
import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
buildEditGenerationInputs,
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
calculateCharacterAnimationPrice,
createCanvasLayerReference,
isCanvasGenerationDialog,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
} from './ImageCanvasGenerationSubmissionModel';
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
@@ -63,6 +31,44 @@ import type {
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendIconDescriptionToDialog,
assignCharacterSpecReference,
assignIconSpecReference,
closeGenerateComposerDialog,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createQuickEditPanelDraft,
createSpecDialogDraft,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionInDialog,
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';
type CanvasSize = { width: number; height: number };
@@ -98,36 +104,6 @@ type GenerationWorkflowOptions = {
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
};
function getViewportWorldCenter({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}) {
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
return {
x: (canvasSize.width / 2 - viewport.x) / safeScale,
y: (canvasSize.height / 2 - viewport.y) / safeScale,
};
}
function setFailedCharacterGenerationIdle(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function setFailedIconGenerationIdle(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
export function useImageCanvasGenerationWorkflow({
layers,
canvasSize,
@@ -149,8 +125,7 @@ export function useImageCanvasGenerationWorkflow({
setImageContextMenu,
}: GenerationWorkflowOptions) {
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] =
useState(false);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
useState(false);
const [
@@ -179,12 +154,16 @@ export function useImageCanvasGenerationWorkflow({
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
) ?? null)
: null;
const quickEditSizeOptions = quickEditPanel
? buildQuickEditSizeOptions(quickEditPanel.size)
: [];
const quickEditModelOptions = quickEditPanel
? buildQuickEditModelOptions(quickEditPanel.model)
: [];
const quickEditSizeOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditSizeOptions(quickEditPanel.size) : [],
[quickEditPanel],
);
const quickEditModelOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditModelOptions(quickEditPanel.model) : [],
[quickEditPanel],
);
const characterAnimationPrice = characterAnimationPanel
? calculateCharacterAnimationPrice(
characterAnimationPanel.resolution,
@@ -197,23 +176,9 @@ export function useImageCanvasGenerationWorkflow({
: DEFAULT_ICON_DESCRIPTIONS;
const openGenerateDialog = useCallback(() => {
const placeholderWidth = 420;
const placeholderHeight = 420;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
openCanvasGenerationDialog({
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
placeholder: {
x: worldCenter.x - placeholderWidth / 2,
y: worldCenter.y - placeholderHeight / 2,
width: placeholderWidth,
height: placeholderHeight,
originalWidth: 2048,
originalHeight: 2048,
},
});
openCanvasGenerationDialog(
createGenerateDialogDraft({ canvasSize, viewport }),
);
setActiveTool('generate');
selectSingleLayer(null);
setQuickEditPanel(null);
@@ -227,23 +192,9 @@ export function useImageCanvasGenerationWorkflow({
const openSpecDialog = useCallback(
(specType: SpecGenerationType) => {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
openCanvasGenerationDialog({
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType,
specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] },
placeholder: {
x: worldCenter.x - SPEC_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - SPEC_FRAME_DISPLAY_SIZE.height / 2,
width: SPEC_FRAME_DISPLAY_SIZE.width,
height: SPEC_FRAME_DISPLAY_SIZE.height,
originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width,
originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height,
},
});
openCanvasGenerationDialog(
createSpecDialogDraft({ canvasSize, viewport, specType }),
);
setIsSpecMenuOpen(false);
setActiveTool('generate');
selectSingleLayer(null);
@@ -260,56 +211,30 @@ export function useImageCanvasGenerationWorkflow({
const openCharacterAnimationPanel = useCallback(
(layer: CanvasLayer) => {
if (layer.assetKind !== 'character') {
const nextPanel = createCharacterAnimationPanelDraft(layer);
if (!nextPanel) {
return;
}
setImageContextMenu(null);
setQuickEditPanel(null);
setCharacterAnimationPanel({
sourceLayerId: layer.id,
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
});
setCharacterAnimationPanel(nextPanel);
selectSingleLayer(layer.id);
},
[selectSingleLayer, setImageContextMenu],
);
const openCharacterGenerationDialog = useCallback(() => {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
setIsSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
openCanvasGenerationDialog({
mode: 'character',
prompt: '',
status: 'idle',
composerOpen: true,
characterSpecReference: null,
characterReferences: [],
imageModel: lastImageModel,
aspectRatio: dimensionOptions.aspectRatios[0],
imageSize:
dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0],
placeholder: {
x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2,
width: CHARACTER_FRAME_DISPLAY_SIZE.width,
height: CHARACTER_FRAME_DISPLAY_SIZE.height,
originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width,
originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height,
},
});
openCanvasGenerationDialog(
createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: lastImageModel,
}),
);
setActiveTool('character');
selectSingleLayer(null);
setQuickEditPanel(null);
@@ -323,37 +248,18 @@ export function useImageCanvasGenerationWorkflow({
]);
const openIconGenerationDialog = useCallback(() => {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
setIsSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsPickingIconSpecFromCanvas(false);
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
openCanvasGenerationDialog({
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
iconSpecReference: null,
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
imageModel: lastImageModel,
aspectRatio: dimensionOptions.aspectRatios[0],
imageSize:
dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0],
placeholder: {
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
},
});
openCanvasGenerationDialog(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: lastImageModel,
}),
);
setActiveTool('icon');
selectSingleLayer(null);
setQuickEditPanel(null);
@@ -372,15 +278,7 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer(null);
setImageContextMenu(null);
setQuickEditPanel(null);
setGenerateDialog({
mode: 'edit',
prompt: sourceLayer.prompt
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
: '',
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
});
setGenerateDialog(createEditDialogDraft(sourceLayer));
setActiveTool('generate');
},
[setActiveTool, setGenerateDialog, setImageContextMenu, setMetadataLayer],
@@ -392,16 +290,7 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer(null);
setGenerateDialog(null);
setCharacterAnimationPanel(null);
setQuickEditPanel({
sourceLayerId: sourceLayer.id,
prompt: '',
size: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
status: 'idle',
});
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
selectSingleLayer(sourceLayer.id);
setActiveTool('generate');
},
@@ -556,13 +445,7 @@ export function useImageCanvasGenerationWorkflow({
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedCharacterGenerationIdle(currentDialog),
characterSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: currentDialog,
assignCharacterSpecReference(currentDialog, layer),
);
setIsPickingCharacterSpecFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
@@ -574,16 +457,7 @@ export function useImageCanvasGenerationWorkflow({
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedCharacterGenerationIdle(currentDialog),
characterReferences: [
...(currentDialog.characterReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: currentDialog,
appendCharacterReference(currentDialog, layer),
);
setIsPickingCharacterReferenceFromCanvas(false);
setImageContextMenu(null);
@@ -593,18 +467,12 @@ export function useImageCanvasGenerationWorkflow({
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignIconSpecReference(currentDialog, layer),
);
if (layer.assetKind !== 'icon-spec') {
return;
}
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...setFailedIconGenerationIdle(currentDialog),
iconSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: currentDialog,
);
setIsPickingIconSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
@@ -615,36 +483,14 @@ export function useImageCanvasGenerationWorkflow({
const updateIconDescription = useCallback(
(index: number, value: string) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...setFailedIconGenerationIdle(currentDialog),
iconDescriptions: (
currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS
).map((description, descriptionIndex) =>
descriptionIndex === index ? value : description,
),
}
: currentDialog,
updateIconDescriptionInDialog(currentDialog, index, value),
);
},
[setGenerateDialog],
);
const addIconDescription = useCallback(() => {
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode !== 'icon') {
return currentDialog;
}
const descriptions =
currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
if (descriptions.length >= ICON_DESCRIPTION_LIMIT) {
return currentDialog;
}
return {
...setFailedIconGenerationIdle(currentDialog),
iconDescriptions: [...descriptions, ''],
};
});
setGenerateDialog(appendIconDescriptionToDialog);
}, [setGenerateDialog]);
const submitIconSpritesheetGeneration = useCallback(
@@ -841,52 +687,17 @@ export function useImageCanvasGenerationWorkflow({
const updateSpecFormValue = useCallback(
(key: keyof SpecFormValues, value: string) => {
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode !== 'spec') {
return currentDialog;
}
const specType = currentDialog.specType ?? 'custom';
return {
...currentDialog,
specValues: {
...DEFAULT_SPEC_FORM_VALUES[specType],
...currentDialog.specValues,
[key]: value,
},
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
};
});
setGenerateDialog((currentDialog) =>
updateSpecFormDialogValue(currentDialog, key, value),
);
},
[setGenerateDialog],
);
const updateCharacterAnimationDuration = useCallback(
(frameCountValue: string) => {
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
(item) => String(item.frameCount) === frameCountValue,
);
if (!option) {
return;
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
frameCount: option.frameCount,
durationSeconds: option.durationSeconds,
status:
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
updateCharacterAnimationDurationPanel(currentPanel, frameCountValue),
);
},
[],
@@ -940,28 +751,12 @@ export function useImageCanvasGenerationWorkflow({
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
(currentDialog?.mode === 'generate' ||
currentDialog?.mode === 'spec' ||
currentDialog?.mode === 'character' ||
currentDialog?.mode === 'icon') &&
currentDialog.status !== 'generating'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
hideGeneratedLayerComposerAfterBlur(currentDialog),
);
}, [setGenerateDialog]);
const closeGenerateComposer = useCallback(() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
setGenerateDialog(closeGenerateComposerDialog);
setActiveTool('select');
}, [setActiveTool, setGenerateDialog]);