接入画板生成视频功能
新增画板底部生成视频入口、Lovart 风格面板、视频图层渲染与元数据展示。 接入 /api/editor/videos/generations 契约与后端 Ark/VectorEngine 视频任务链路。 统一编辑器生成类泥点配置,并补充 UI 设计图、参考图与生成面板结构测试。 更新编辑器技术方案、生成类面板方案和 Hermes 共享决策/踩坑记录。
This commit is contained in:
@@ -150,6 +150,7 @@ export function serializeLayer(
|
||||
originalHeight: layer.originalHeight,
|
||||
zIndex: layer.zIndex,
|
||||
sourceType: layer.sourceType,
|
||||
mediaType: layer.mediaType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
@@ -207,6 +208,7 @@ export function hydrateLayer(
|
||||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
: 'uploaded',
|
||||
mediaType: snapshot.mediaType === 'video' ? 'video' : 'image',
|
||||
prompt: stringOrNull(snapshot.prompt),
|
||||
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
||||
model: stringOrNull(snapshot.model),
|
||||
@@ -406,7 +408,9 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
return value === 'spec' ||
|
||||
value === 'character' ||
|
||||
value === 'icon' ||
|
||||
value === 'icon-spec'
|
||||
value === 'icon-spec' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'video'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,21 @@ import type {
|
||||
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind = 'spec' | 'character' | 'icon' | 'icon-spec';
|
||||
export type CanvasAssetKind =
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'icon-spec'
|
||||
| 'ui-design'
|
||||
| 'video';
|
||||
|
||||
export type CanvasMediaType = 'image' | 'video';
|
||||
|
||||
export type EditorAsset = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
width: number;
|
||||
height: number;
|
||||
folderId: string;
|
||||
@@ -52,6 +61,7 @@ export type CanvasLayer = {
|
||||
resourceId: string;
|
||||
title: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -89,9 +99,11 @@ export type CanvasTool =
|
||||
| 'hand'
|
||||
| 'upload'
|
||||
| 'generate'
|
||||
| 'video'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'ui-design'
|
||||
| 'text'
|
||||
| 'shape'
|
||||
| 'export';
|
||||
@@ -124,7 +136,14 @@ export type CharacterReferenceImage = {
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon';
|
||||
mode:
|
||||
| 'generate'
|
||||
| 'edit'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'ui-design'
|
||||
| 'video';
|
||||
prompt: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
@@ -133,11 +152,19 @@ export type GenerateDialogState = {
|
||||
specType?: SpecGenerationType;
|
||||
specValues?: SpecFormValues;
|
||||
specReference?: CharacterReferenceImage | null;
|
||||
generationReferences?: CharacterReferenceImage[];
|
||||
characterSpecReference?: CharacterReferenceImage | null;
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
iconDescriptions?: string[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
videoModel?: string;
|
||||
videoAspectRatio?: string;
|
||||
videoResolution?: string;
|
||||
videoDurationSeconds?: 4 | 5;
|
||||
videoMode?: 'std';
|
||||
videoSound?: 'off';
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
@@ -219,10 +246,12 @@ export type CharacterAnimationPanelState = {
|
||||
|
||||
export type UploadTarget =
|
||||
| 'asset'
|
||||
| 'generation-reference'
|
||||
| 'spec-reference'
|
||||
| 'character-spec'
|
||||
| 'character-reference'
|
||||
| 'icon-spec';
|
||||
| 'icon-spec'
|
||||
| 'ui-design-icon-spec';
|
||||
|
||||
export type SnapGuide = {
|
||||
vertical?: number;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
createRef,
|
||||
type ComponentProps,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
|
||||
function mockStateSetter<T>() {
|
||||
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
||||
> = {},
|
||||
) {
|
||||
const props: ComponentProps<typeof ImageCanvasGenerationComposerView> = {
|
||||
specToolWrapRef: createRef(),
|
||||
characterSpecButtonRef: createRef(),
|
||||
characterReferenceButtonRef: createRef(),
|
||||
generationReferenceButtonRef: createRef(),
|
||||
iconSpecButtonRef: createRef(),
|
||||
isSpecMenuOpen: false,
|
||||
isCharacterSpecMenuOpen: false,
|
||||
isCharacterReferenceMenuOpen: false,
|
||||
isGenerationReferenceMenuOpen: false,
|
||||
isIconSpecMenuOpen: false,
|
||||
isUiDesignSpecMenuOpen: false,
|
||||
isPickingCharacterSpecFromCanvas: false,
|
||||
isPickingCharacterReferenceFromCanvas: false,
|
||||
isPickingGenerationReferenceFromCanvas: false,
|
||||
isPickingIconSpecFromCanvas: false,
|
||||
isPickingUiDesignSpecFromCanvas: false,
|
||||
generateDialog,
|
||||
generationComposerStyle: { left: 320, top: 240 },
|
||||
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
|
||||
quickEditPanel: null,
|
||||
quickEditSourceLayer: null,
|
||||
quickEditPanelStyle: null,
|
||||
quickEditSizeOptions: ['1024x1024'],
|
||||
quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }],
|
||||
characterAnimationPanel: null,
|
||||
characterAnimationSourceLayer: null,
|
||||
characterAnimationPanelStyle: null,
|
||||
characterAnimationPrice: 40,
|
||||
setGenerateDialog: mockStateSetter<GenerateDialogState | null>(),
|
||||
setQuickEditPanel:
|
||||
mockStateSetter<
|
||||
ComponentProps<
|
||||
typeof ImageCanvasGenerationComposerView
|
||||
>['quickEditPanel']
|
||||
>(),
|
||||
setCharacterAnimationPanel:
|
||||
mockStateSetter<
|
||||
ComponentProps<
|
||||
typeof ImageCanvasGenerationComposerView
|
||||
>['characterAnimationPanel']
|
||||
>(),
|
||||
setIsCharacterSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsCharacterReferenceMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsGenerationReferenceMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
onOpenSpecDialog: vi.fn(),
|
||||
onRequestUpload: vi.fn(),
|
||||
onSubmitImageGeneration: vi.fn(),
|
||||
onSubmitIconSpritesheetGeneration: vi.fn(),
|
||||
onSubmitQuickEdit: vi.fn(),
|
||||
onSubmitCharacterAnimation: vi.fn(),
|
||||
onCloseGenerateComposer: vi.fn(),
|
||||
onUpdateSpecFormValue: vi.fn(),
|
||||
onUpdateIconDescription: vi.fn(),
|
||||
onAddIconDescription: vi.fn(),
|
||||
onUpdateCharacterAnimationDuration: vi.fn(),
|
||||
onRememberImageModel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(<ImageCanvasGenerationComposerView {...props} />);
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
|
||||
renderComposer({
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: 'UI设计图标素材规范' })
|
||||
.parentElement?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
|
||||
).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('让生成规范图片面板复用生成类面板 shell 和底部按钮结构', () => {
|
||||
renderComposer({
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
specType: 'character',
|
||||
specValues: {
|
||||
playSetting: '战棋类RPG玩法',
|
||||
artStyle: '像素风',
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
specReference: null,
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '提交生成规范' }).className,
|
||||
).toContain('image-canvas-editor__generation-submit');
|
||||
});
|
||||
|
||||
it('让图标素材规范生成面板也保留首行参考区', () => {
|
||||
renderComposer({
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
specType: 'icon',
|
||||
specValues: {
|
||||
playSetting: '休闲小游戏',
|
||||
artStyle: '清爽卡通',
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
specReference: null,
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
});
|
||||
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
const setIsGenerationReferenceMenuOpen = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [],
|
||||
},
|
||||
{
|
||||
isGenerationReferenceMenuOpen: true,
|
||||
onRequestUpload,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
||||
|
||||
expect(onRequestUpload).not.toHaveBeenCalled();
|
||||
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '上传图片' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('生成视频参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
const setIsGenerationReferenceMenuOpen = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [],
|
||||
videoModel: 'seedance2.0',
|
||||
videoAspectRatio: '16:9',
|
||||
videoDurationSeconds: 4,
|
||||
videoResolution: '480p',
|
||||
videoMode: 'std',
|
||||
videoSound: 'off',
|
||||
},
|
||||
{
|
||||
isGenerationReferenceMenuOpen: true,
|
||||
onRequestUpload,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '添加视频参考图' }),
|
||||
);
|
||||
|
||||
expect(onRequestUpload).not.toHaveBeenCalled();
|
||||
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '上传图片' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
const setIsGenerationReferenceMenuOpen = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
specType: 'character',
|
||||
specValues: {
|
||||
playSetting: '战棋类RPG玩法',
|
||||
artStyle: '像素风',
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
specReference: null,
|
||||
},
|
||||
{
|
||||
isGenerationReferenceMenuOpen: true,
|
||||
onRequestUpload,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '参考图' }));
|
||||
|
||||
expect(onRequestUpload).not.toHaveBeenCalled();
|
||||
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy();
|
||||
expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
EditorIconSpritesheetGenerationResult,
|
||||
EditorIconSpritesheetIconResult,
|
||||
EditorImageGenerationResult,
|
||||
EditorVideoGenerationResult,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
|
||||
import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel';
|
||||
@@ -43,6 +44,16 @@ type IconSpritesheetResultLayerOptions = {
|
||||
frame?: GenerateDialogState['placeholder'];
|
||||
};
|
||||
|
||||
type VideoResultLayerOptions = {
|
||||
generated: EditorVideoGenerationResult;
|
||||
generatedIndex: number;
|
||||
title: string;
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
frame?: GenerateDialogState['placeholder'];
|
||||
};
|
||||
|
||||
function getViewportWorldCenter({
|
||||
canvasSize,
|
||||
viewport,
|
||||
@@ -235,3 +246,53 @@ export function createIconSpritesheetResultLayers({
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
export function createVideoResultLayer({
|
||||
generated,
|
||||
generatedIndex,
|
||||
title,
|
||||
canvasSize,
|
||||
viewport,
|
||||
generationInputs,
|
||||
frame,
|
||||
}: VideoResultLayerOptions): CanvasLayer {
|
||||
const originalWidth = generated.width || 1280;
|
||||
const originalHeight = generated.height || 720;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{ width: 560, height: 315 },
|
||||
);
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const frameX =
|
||||
frame && frame.width > 0
|
||||
? frame.x + frame.width / 2 - width / 2
|
||||
: undefined;
|
||||
const frameY =
|
||||
frame && frame.height > 0
|
||||
? frame.y + frame.height / 2 - height / 2
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: `layer-video-${generatedIndex}`,
|
||||
resourceId: `local-resource-video-${generatedIndex}`,
|
||||
title,
|
||||
src: generated.videoSrc,
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
zIndex: generatedIndex + 10,
|
||||
sourceType: generated.sourceType,
|
||||
prompt: generated.prompt,
|
||||
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,23 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isGeneratedLayer } from './ImageCanvasEditorModel';
|
||||
|
||||
export const SPEC_GENERATION_COST = 5;
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
|
||||
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
|
||||
image: 12,
|
||||
spec: 5,
|
||||
character: 12,
|
||||
icon: 12,
|
||||
uiDesign: 12,
|
||||
videoPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
characterAnimationPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
} as const;
|
||||
export const SPEC_GENERATION_COST = EDITOR_GENERATION_MUD_POINT_CONFIG.spec;
|
||||
export const SPEC_GENERATION_SIZE = '2048x1152';
|
||||
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
@@ -23,6 +39,8 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
|
||||
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
|
||||
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
|
||||
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
|
||||
export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
@@ -86,6 +104,18 @@ export const CHARACTER_ANIMATION_DURATION_OPTIONS = [
|
||||
{ label: '40帧·5秒', frameCount: 40, durationSeconds: 5 },
|
||||
{ label: '48帧·6秒', frameCount: 48, durationSeconds: 6 },
|
||||
] as const;
|
||||
export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 1280, height: 720 };
|
||||
export const VIDEO_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
export const EDITOR_VIDEO_MODEL_OPTIONS = [
|
||||
{ label: 'Seedance 2.0', value: 'seedance2.0' },
|
||||
{ label: 'Seedance 2.0 Fast', value: 'seedance2.0-fast' },
|
||||
{ label: 'Kling 3.0', value: 'kling3.0' },
|
||||
{ label: 'Kling 3.0 Omni', value: 'kling3.0-omni' },
|
||||
] as const;
|
||||
export const EDITOR_VIDEO_DURATION_OPTIONS = [
|
||||
{ label: '4秒', value: '4' },
|
||||
{ label: '5秒', value: '5' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_SPEC_FORM_VALUES: Record<
|
||||
SpecGenerationType,
|
||||
@@ -215,6 +245,12 @@ export function getLayerKindLabel(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return '图标规范';
|
||||
}
|
||||
if (layer.assetKind === 'ui-design') {
|
||||
return 'UI设计';
|
||||
}
|
||||
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
|
||||
return '视频';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -231,6 +267,12 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return '图标素材规范图片';
|
||||
}
|
||||
if (layer.assetKind === 'ui-design') {
|
||||
return 'UI设计图';
|
||||
}
|
||||
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
|
||||
return '生成视频';
|
||||
}
|
||||
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
|
||||
}
|
||||
|
||||
@@ -238,7 +280,20 @@ export function calculateCharacterAnimationPrice(
|
||||
resolution: EditorCharacterAnimationResolution,
|
||||
durationSeconds: number,
|
||||
) {
|
||||
return (resolution === '720p' ? 20 : 10) * durationSeconds;
|
||||
return (
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond[resolution] *
|
||||
durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateEditorVideoPrice(
|
||||
resolution: '480p' | '720p',
|
||||
durationSeconds: number,
|
||||
) {
|
||||
return (
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond[resolution] *
|
||||
durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
|
||||
@@ -264,10 +319,31 @@ export function createGenerationInputField(
|
||||
return normalizedValue ? [{ title, value: normalizedValue }] : [];
|
||||
}
|
||||
|
||||
export function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs {
|
||||
export function buildImageGenerationInputs(
|
||||
prompt: string,
|
||||
references?: CharacterReferenceImage[],
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('生成提示词', prompt),
|
||||
references: [],
|
||||
references: (references ?? []).map((reference, index) => ({
|
||||
title: `参考图 ${index + 1}`,
|
||||
label: reference.label,
|
||||
src: reference.src,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVideoGenerationInputs(
|
||||
prompt: string,
|
||||
references?: CharacterReferenceImage[],
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('视频描述', prompt),
|
||||
references: (references ?? []).map((reference, index) => ({
|
||||
title: `参考图 ${index + 1}`,
|
||||
label: reference.label,
|
||||
src: reference.src,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,6 +404,24 @@ export function buildCharacterGenerationInputs(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUiDesignGenerationInputs(
|
||||
prompt: string,
|
||||
specReference: CharacterReferenceImage | null | undefined,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('用户输入', prompt),
|
||||
references: specReference
|
||||
? [
|
||||
{
|
||||
title: '图标素材规范',
|
||||
label: specReference.label,
|
||||
src: specReference.src,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIconGenerationInputs(
|
||||
iconDescriptions: string[],
|
||||
specReference: CharacterReferenceImage,
|
||||
@@ -372,7 +466,9 @@ export function isCanvasGenerationDialog(
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon'),
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'video'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,6 +484,12 @@ export function getGenerationFrameAriaLabel(
|
||||
if (dialog.mode === 'icon') {
|
||||
return '图标素材生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return 'UI设计图生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'video') {
|
||||
return '视频生成占位图';
|
||||
}
|
||||
return '图像生成占位图';
|
||||
}
|
||||
|
||||
@@ -401,6 +503,12 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
|
||||
if (dialog.mode === 'icon') {
|
||||
return 'Icon Generator';
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return 'UI Design Generator';
|
||||
}
|
||||
if (dialog.mode === 'video') {
|
||||
return 'Video Generator';
|
||||
}
|
||||
return 'Image Generator';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
chooseGenerationPlacement,
|
||||
centerViewportOnPlacement,
|
||||
} from './ImageCanvasGenerationPlacementModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
|
||||
const canvasSize = { width: 900, height: 640 };
|
||||
const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 };
|
||||
const frame = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
};
|
||||
|
||||
function layer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '图片',
|
||||
src: '/image.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 100,
|
||||
originalHeight: 100,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
it('places an empty-canvas generation frame at the current viewport center', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame,
|
||||
layers: [],
|
||||
generationDialogs: [],
|
||||
});
|
||||
|
||||
expect(placement).toEqual({
|
||||
x: 10,
|
||||
y: -60,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the nearest non-overlapping gap from visible layers and generation placeholders', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [
|
||||
layer({ id: 'center', x: 170, y: 110, width: 120, height: 120 }),
|
||||
layer({ id: 'right', x: 322, y: 110, width: 100, height: 120 }),
|
||||
layer({
|
||||
id: 'hidden',
|
||||
hidden: true,
|
||||
x: 18,
|
||||
y: 118,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
],
|
||||
generationDialogs: [
|
||||
{
|
||||
id: 'dialog-left',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 18,
|
||||
y: 118,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(170);
|
||||
expect(placement.y).toBe(-22);
|
||||
});
|
||||
|
||||
it('centers the viewport on the chosen placement without changing zoom', () => {
|
||||
const nextViewport = centerViewportOnPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
placement: {
|
||||
x: 170,
|
||||
y: 262,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextViewport).toEqual({ x: 10, y: -304, scale: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
type GenerationFrame = NonNullable<GenerateDialogState['placeholder']>;
|
||||
type Rect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
const GENERATION_PLACEMENT_GAP = 32;
|
||||
const MAX_PLACEMENT_RING = 12;
|
||||
|
||||
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 expandRect(rect: Rect, gap: number): Rect {
|
||||
return {
|
||||
x: rect.x - gap,
|
||||
y: rect.y - gap,
|
||||
width: rect.width + gap * 2,
|
||||
height: rect.height + gap * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function rectsOverlap(a: Rect, b: Rect) {
|
||||
return (
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
function distanceSquared(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
) {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
function buildBlockingRects({
|
||||
layers,
|
||||
generationDialogs,
|
||||
}: {
|
||||
layers: CanvasLayer[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
}) {
|
||||
return [
|
||||
...layers
|
||||
.filter((layer) => !layer.hidden)
|
||||
.map((layer) => ({
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
})),
|
||||
...generationDialogs.flatMap((dialog) =>
|
||||
dialog.placeholder
|
||||
? [
|
||||
{
|
||||
x: dialog.placeholder.x,
|
||||
y: dialog.placeholder.y,
|
||||
width: dialog.placeholder.width,
|
||||
height: dialog.placeholder.height,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
].map((rect) => expandRect(rect, GENERATION_PLACEMENT_GAP));
|
||||
}
|
||||
|
||||
function isFree(candidate: Rect, blockingRects: Rect[]) {
|
||||
return !blockingRects.some((rect) => rectsOverlap(candidate, rect));
|
||||
}
|
||||
|
||||
function createCandidate({
|
||||
center,
|
||||
frame,
|
||||
offsetX,
|
||||
offsetY,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
frame: Pick<GenerationFrame, 'width' | 'height'>;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}): Rect {
|
||||
return {
|
||||
x: center.x - frame.width / 2 + offsetX,
|
||||
y: center.y - frame.height / 2 + offsetY,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
};
|
||||
}
|
||||
|
||||
function pushUniqueCandidate(candidates: Rect[], candidate: Rect) {
|
||||
if (
|
||||
candidates.some(
|
||||
(current) => current.x === candidate.x && current.y === candidate.y,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
function buildPlacementCandidates({
|
||||
center,
|
||||
frame,
|
||||
blockingRects,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
frame: Pick<GenerationFrame, 'width' | 'height'>;
|
||||
blockingRects: Rect[];
|
||||
}) {
|
||||
const baseX = center.x - frame.width / 2;
|
||||
const baseY = center.y - frame.height / 2;
|
||||
const stepX = frame.width + GENERATION_PLACEMENT_GAP;
|
||||
const stepY = frame.height + GENERATION_PLACEMENT_GAP;
|
||||
const candidates: Rect[] = [];
|
||||
pushUniqueCandidate(candidates, {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
});
|
||||
|
||||
blockingRects.forEach((rect) => {
|
||||
[
|
||||
{ x: rect.x + rect.width, y: baseY },
|
||||
{ x: baseX, y: rect.y + rect.height },
|
||||
{ x: rect.x - frame.width, y: baseY },
|
||||
{ x: baseX, y: rect.y - frame.height },
|
||||
{ x: rect.x + rect.width, y: rect.y + rect.height },
|
||||
{ x: rect.x - frame.width, y: rect.y + rect.height },
|
||||
{ x: rect.x + rect.width, y: rect.y - frame.height },
|
||||
{ x: rect.x - frame.width, y: rect.y - frame.height },
|
||||
].forEach((candidate) =>
|
||||
pushUniqueCandidate(candidates, {
|
||||
...candidate,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let ring = 1; ring <= MAX_PLACEMENT_RING; ring += 1) {
|
||||
const offsets = [
|
||||
{ x: ring, y: 0 },
|
||||
{ x: 0, y: ring },
|
||||
{ x: -ring, y: 0 },
|
||||
{ x: 0, y: -ring },
|
||||
{ x: ring, y: ring },
|
||||
{ x: -ring, y: ring },
|
||||
{ x: ring, y: -ring },
|
||||
{ x: -ring, y: -ring },
|
||||
];
|
||||
offsets.forEach((offset) =>
|
||||
pushUniqueCandidate(
|
||||
candidates,
|
||||
createCandidate({
|
||||
center,
|
||||
frame,
|
||||
offsetX: offset.x * stepX,
|
||||
offsetY: offset.y * stepY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return candidates.sort((a, b) => {
|
||||
const aDistance = distanceSquared(
|
||||
{ x: a.x + a.width / 2, y: a.y + a.height / 2 },
|
||||
center,
|
||||
);
|
||||
const bDistance = distanceSquared(
|
||||
{ x: b.x + b.width / 2, y: b.y + b.height / 2 },
|
||||
center,
|
||||
);
|
||||
if (aDistance !== bDistance) {
|
||||
return aDistance - bDistance;
|
||||
}
|
||||
const aDownBias = a.y >= baseY ? 0 : 1;
|
||||
const bDownBias = b.y >= baseY ? 0 : 1;
|
||||
if (aDownBias !== bDownBias) {
|
||||
return aDownBias - bDownBias;
|
||||
}
|
||||
if (a.x !== b.x) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
return a.y - b.y;
|
||||
});
|
||||
}
|
||||
|
||||
export function chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame,
|
||||
layers,
|
||||
generationDialogs,
|
||||
}: {
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
frame: GenerationFrame;
|
||||
layers: CanvasLayer[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
}): GenerationFrame {
|
||||
const center = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const blockingRects = buildBlockingRects({ layers, generationDialogs });
|
||||
const candidates = buildPlacementCandidates({ center, frame, blockingRects });
|
||||
const fallbackPlacement = createCandidate({
|
||||
center,
|
||||
frame,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
const placement =
|
||||
candidates.find((candidate) => isFree(candidate, blockingRects)) ??
|
||||
fallbackPlacement;
|
||||
|
||||
return {
|
||||
...frame,
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function centerViewportOnPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
placement,
|
||||
}: {
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
placement: GenerationFrame;
|
||||
}): CanvasViewport {
|
||||
const scale = viewport.scale > 0 ? viewport.scale : 1;
|
||||
return {
|
||||
x: canvasSize.width / 2 - (placement.x + placement.width / 2) * scale,
|
||||
y: canvasSize.height / 2 - (placement.y + placement.height / 2) * scale,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,42 @@ describe('ImageCanvasMetadataModalView', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders generated video metadata with video labels and close action', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={createLayer({
|
||||
title: '生成视频 1',
|
||||
src: '/generated-editor-videos/video-task-1/preview.mp4',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
model: 'kling3.0-omni',
|
||||
taskId: 'video-task-1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '视频描述', value: '让角色向镜头挥手' }],
|
||||
references: [],
|
||||
},
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '视频信息' });
|
||||
|
||||
expect(within(dialog).getByText('视频类型')).toBeTruthy();
|
||||
expect(within(dialog).getByText('生成视频')).toBeTruthy();
|
||||
expect(within(dialog).getByText('视频描述')).toBeTruthy();
|
||||
expect(within(dialog).getByText('让角色向镜头挥手')).toBeTruthy();
|
||||
expect(within(dialog).getByText('kling3.0-omni')).toBeTruthy();
|
||||
expect(within(dialog).getByText('1280 x 720 px')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭视频信息' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not render a dialog when no layer is selected', () => {
|
||||
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -19,16 +19,16 @@ export function ImageCanvasMetadataModalView({
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={Boolean(layer)}
|
||||
title="图片信息"
|
||||
title={layer?.mediaType === 'video' ? '视频信息' : '图片信息'}
|
||||
size="sm"
|
||||
closeLabel="关闭图片信息"
|
||||
closeLabel={layer?.mediaType === 'video' ? '关闭视频信息' : '关闭图片信息'}
|
||||
onClose={onClose}
|
||||
panelClassName="image-canvas-editor__metadata-dialog"
|
||||
bodyClassName="image-canvas-editor__metadata-body"
|
||||
>
|
||||
{layer ? (
|
||||
<dl className="image-canvas-editor__metadata-grid">
|
||||
<dt>图片类型</dt>
|
||||
<dt>{layer.mediaType === 'video' ? '视频类型' : '图片类型'}</dt>
|
||||
<dd>{formatLayerImageType(layer)}</dd>
|
||||
<dt>生成输入</dt>
|
||||
<dd className="image-canvas-editor__metadata-inputs">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -72,10 +78,13 @@ function createGenerated(overrides = {}) {
|
||||
|
||||
function GenerationWorkflowHarness({
|
||||
initialLayers = [createLayer()],
|
||||
initialViewport = { x: 10, y: 20, scale: 2 },
|
||||
}: {
|
||||
initialLayers?: CanvasLayer[];
|
||||
initialViewport?: { x: number; y: number; scale: number };
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
||||
const [viewport, setViewport] = useState(initialViewport);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
@@ -93,7 +102,9 @@ function GenerationWorkflowHarness({
|
||||
const workflow = useImageCanvasGenerationWorkflow({
|
||||
layers,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
viewport,
|
||||
setViewport,
|
||||
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
||||
layerCounterRef,
|
||||
generateDialog: dialogs.generateDialog,
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
@@ -126,6 +137,12 @@ function GenerationWorkflowHarness({
|
||||
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
||||
<span data-testid="metadata">{metadataLayer?.id ?? '-'}</span>
|
||||
<span data-testid="image-context">{imageContextMenu ? 'open' : '-'}</span>
|
||||
<span data-testid="viewport">{`${viewport.x}:${viewport.y}:${viewport.scale}`}</span>
|
||||
<span data-testid="placeholder">
|
||||
{activeDialog?.placeholder
|
||||
? `${activeDialog.placeholder.x}:${activeDialog.placeholder.y}:${activeDialog.placeholder.width}:${activeDialog.placeholder.height}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="layers">
|
||||
{layers
|
||||
.map(
|
||||
@@ -139,6 +156,11 @@ function GenerationWorkflowHarness({
|
||||
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="generation-references">
|
||||
{activeDialog?.generationReferences
|
||||
?.map((reference) => reference.label)
|
||||
.join('|') ?? '-'}
|
||||
</span>
|
||||
<span data-testid="quick-edit">
|
||||
{workflow.quickEditPanel
|
||||
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
||||
@@ -190,7 +212,9 @@ function GenerationWorkflowHarness({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.openCharacterAnimationPanel(createLayer({ id: 'layer-plain' }))
|
||||
workflow.openCharacterAnimationPanel(
|
||||
createLayer({ id: 'layer-plain' }),
|
||||
)
|
||||
}
|
||||
>
|
||||
打开普通动画
|
||||
@@ -217,6 +241,16 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
提交生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickGenerationReferenceFromLayer(
|
||||
createLayer({ id: 'layer-reference', title: '画布参考图' }),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择画布参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -240,21 +274,22 @@ function GenerationWorkflowHarness({
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.setQuickEditPanel((currentPanel) =>
|
||||
currentPanel ? { ...currentPanel, prompt: '快速修图' } : currentPanel,
|
||||
currentPanel
|
||||
? { ...currentPanel, prompt: '快速修图' }
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
填写快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void workflow.submitQuickEdit()}
|
||||
>
|
||||
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
|
||||
提交快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.clearDeletedLayerGenerationState('layer-source')}
|
||||
onClick={() =>
|
||||
workflow.clearDeletedLayerGenerationState('layer-source')
|
||||
}
|
||||
>
|
||||
清理源图状态
|
||||
</button>
|
||||
@@ -298,6 +333,29 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'center-layer',
|
||||
x: 0,
|
||||
y: -80,
|
||||
width: 460,
|
||||
height: 460,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'-452:-60:420:420',
|
||||
);
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('934:20:2');
|
||||
});
|
||||
|
||||
it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '一张生成图' }),
|
||||
@@ -329,6 +387,30 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds picked canvas layers as normal generation references and submits them', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '参考生成图' }),
|
||||
);
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' }));
|
||||
|
||||
expect(screen.getByTestId('generation-references').textContent).toBe(
|
||||
'画布参考图',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
referenceImageSrcs: ['data:image/png;base64,source'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('submits spec generation with reference image and reference prompt semantics', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: 'UI规范图' }),
|
||||
@@ -344,6 +426,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect.objectContaining({
|
||||
size: '2048x1152',
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
prompt: expect.stringContaining('参考图生成规范'),
|
||||
}),
|
||||
|
||||
@@ -78,6 +78,8 @@ function KeyboardShortcutsHarness({
|
||||
const [imageContextMenuOpen, setImageContextMenuOpen] = useState(true);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(true);
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true);
|
||||
const [, setIsGenerationReferenceMenuOpen] = useState(true);
|
||||
const [, setIsPickingGenerationReferenceFromCanvas] = useState(true);
|
||||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true);
|
||||
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
|
||||
useState(true);
|
||||
@@ -90,6 +92,9 @@ function KeyboardShortcutsHarness({
|
||||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true);
|
||||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||||
useState(true);
|
||||
const [, setIsUiDesignSpecMenuOpen] = useState(true);
|
||||
const [, setIsPickingUiDesignSpecFromCanvas] =
|
||||
useState(true);
|
||||
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
||||
const [shiftPressed, setShiftPressed] = useState(false);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
|
||||
@@ -116,12 +121,16 @@ function KeyboardShortcutsHarness({
|
||||
),
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsUiDesignSpecMenuOpen,
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
});
|
||||
|
||||
@@ -30,12 +30,16 @@ type UseImageCanvasKeyboardShortcutsOptions = {
|
||||
) => void;
|
||||
closeEditorChromePanels: () => void;
|
||||
setIsSpecMenuOpen: (open: boolean) => void;
|
||||
setIsGenerationReferenceMenuOpen: (open: boolean) => void;
|
||||
setIsPickingGenerationReferenceFromCanvas: (picking: boolean) => void;
|
||||
setIsCharacterSpecMenuOpen: (open: boolean) => void;
|
||||
setIsCharacterReferenceMenuOpen: (open: boolean) => void;
|
||||
setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void;
|
||||
setIsPickingCharacterReferenceFromCanvas: (picking: boolean) => void;
|
||||
setIsIconSpecMenuOpen: (open: boolean) => void;
|
||||
setIsPickingIconSpecFromCanvas: (picking: boolean) => void;
|
||||
setIsUiDesignSpecMenuOpen: (open: boolean) => void;
|
||||
setIsPickingUiDesignSpecFromCanvas: (picking: boolean) => void;
|
||||
setIsSpacePanning: (panning: boolean) => void;
|
||||
setShiftPressed: (pressed: boolean) => void;
|
||||
};
|
||||
@@ -61,7 +65,8 @@ function isCanvasGenerationPlaceholderDialog(
|
||||
(dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'spec' ||
|
||||
dialog?.mode === 'character' ||
|
||||
dialog?.mode === 'icon')
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'ui-design')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,12 +83,16 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setQuickEditPanel,
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsUiDesignSpecMenuOpen,
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
}: UseImageCanvasKeyboardShortcutsOptions) {
|
||||
@@ -91,6 +100,8 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
const closeTransientEditorPanels = () => {
|
||||
closeEditorChromePanels();
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setQuickEditPanel((currentPanel) =>
|
||||
@@ -102,6 +113,8 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (!currentDialog || currentDialog.status === 'generating') {
|
||||
return currentDialog;
|
||||
@@ -118,6 +131,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
if (currentDialog.mode === 'icon') {
|
||||
return currentDialog;
|
||||
}
|
||||
if (currentDialog.mode === 'ui-design') {
|
||||
return currentDialog;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -154,6 +170,8 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
event.preventDefault();
|
||||
setGenerateDialog(null);
|
||||
setActiveTool('select');
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
@@ -200,6 +218,8 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setContextMenu,
|
||||
setGenerateDialog,
|
||||
setImageContextMenu,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
|
||||
@@ -108,13 +108,17 @@ function asCanvasGenerationDialog(
|
||||
|
||||
function StageInteractionsHarness({
|
||||
pickCharacterSpecFromLayer = vi.fn(),
|
||||
pickGenerationReferenceFromLayer = vi.fn(),
|
||||
pickIconSpecFromLayer = vi.fn(),
|
||||
isPickingGenerationReferenceFromCanvas = false,
|
||||
activateCanvasGenerationDialog = vi.fn(),
|
||||
moveViewportFromMinimapPointer = vi.fn(),
|
||||
updateViewportFromMinimapDrag = vi.fn(),
|
||||
}: {
|
||||
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
|
||||
pickIconSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||
isPickingGenerationReferenceFromCanvas?: boolean;
|
||||
activateCanvasGenerationDialog?: (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
@@ -175,16 +179,20 @@ function StageInteractionsHarness({
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
isPickingCharacterSpecFromCanvas: false,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas: false,
|
||||
isPickingIconSpecFromCanvas: false,
|
||||
isPickingUiDesignSpecFromCanvas: false,
|
||||
clearCanvasFocus: () => {
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setClearCount((currentCount) => currentCount + 1);
|
||||
},
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickCharacterReferenceFromLayer: vi.fn(),
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer: vi.fn(),
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: (dialogId, updater) => {
|
||||
setGenerateDialog((currentDialog) => {
|
||||
|
||||
@@ -54,12 +54,16 @@ type UseImageCanvasStageInteractionsOptions = {
|
||||
generateDialog: GenerateDialogState | null;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
isPickingCharacterSpecFromCanvas: boolean;
|
||||
isPickingGenerationReferenceFromCanvas: boolean;
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
isPickingUiDesignSpecFromCanvas: boolean;
|
||||
clearCanvasFocus: () => void;
|
||||
pickCharacterSpecFromLayer: (layer: CanvasLayer) => void;
|
||||
pickGenerationReferenceFromLayer: (layer: CanvasLayer) => void;
|
||||
pickCharacterReferenceFromLayer: (layer: CanvasLayer) => void;
|
||||
pickIconSpecFromLayer: (layer: CanvasLayer) => void;
|
||||
pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void;
|
||||
activateCanvasGenerationDialog: (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
@@ -92,12 +96,16 @@ export function useImageCanvasStageInteractions({
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isPickingUiDesignSpecFromCanvas,
|
||||
clearCanvasFocus,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
moveViewportFromMinimapPointer,
|
||||
@@ -190,6 +198,18 @@ export function useImageCanvasStageInteractions({
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isPickingGenerationReferenceFromCanvas &&
|
||||
(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'video' ||
|
||||
generateDialog?.mode === 'spec')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressNextLayerClickRef.current = true;
|
||||
pickGenerationReferenceFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isPickingCharacterSpecFromCanvas &&
|
||||
generateDialog?.mode === 'character'
|
||||
@@ -217,6 +237,16 @@ export function useImageCanvasStageInteractions({
|
||||
pickIconSpecFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isPickingUiDesignSpecFromCanvas &&
|
||||
generateDialog?.mode === 'ui-design'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressNextLayerClickRef.current = true;
|
||||
pickUiDesignSpecFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -244,12 +274,15 @@ export function useImageCanvasStageInteractions({
|
||||
effectiveTool,
|
||||
generateDialog?.mode,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
layers,
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
selectedLayerIds,
|
||||
setGenerateDialog,
|
||||
setSelectedLayerId,
|
||||
@@ -271,6 +304,9 @@ export function useImageCanvasStageInteractions({
|
||||
if (isPickingCharacterSpecFromCanvas) {
|
||||
return;
|
||||
}
|
||||
if (isPickingGenerationReferenceFromCanvas) {
|
||||
return;
|
||||
}
|
||||
if (isPickingCharacterReferenceFromCanvas) {
|
||||
return;
|
||||
}
|
||||
@@ -289,6 +325,7 @@ export function useImageCanvasStageInteractions({
|
||||
},
|
||||
[
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
onCloseImageContextMenu,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -130,7 +136,7 @@ function UploadWorkflowHarness({
|
||||
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
|
||||
<span data-testid="dialog">
|
||||
{generateDialog
|
||||
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}`
|
||||
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<button
|
||||
@@ -153,6 +159,19 @@ function UploadWorkflowHarness({
|
||||
>
|
||||
上传到画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGenerateDialog({
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
})
|
||||
}
|
||||
>
|
||||
准备图片生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -180,6 +199,12 @@ function UploadWorkflowHarness({
|
||||
>
|
||||
准备规范生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.setUploadTarget('generation-reference')}
|
||||
>
|
||||
选择生成参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.setUploadTarget('spec-reference')}
|
||||
@@ -262,7 +287,9 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
openEditorLoginModal={openEditorLoginModal}
|
||||
/>,
|
||||
);
|
||||
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
|
||||
const uploadInput = screen.getByLabelText(
|
||||
'上传图片文件',
|
||||
) as HTMLInputElement;
|
||||
const clickUploadInput = vi.spyOn(uploadInput, 'click');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '请求素材上传' }));
|
||||
@@ -279,7 +306,9 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
openEditorLoginModal={openEditorLoginModal}
|
||||
/>,
|
||||
);
|
||||
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
|
||||
const uploadInput = screen.getByLabelText(
|
||||
'上传图片文件',
|
||||
) as HTMLInputElement;
|
||||
const clickUploadInput = vi.spyOn(uploadInput, 'click');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '请求角色规范上传' }));
|
||||
@@ -370,7 +399,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'character:failed:-:0:-:-',
|
||||
'character:failed:-:0:-:-:0:-',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -388,6 +417,25 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches file input uploads to normal generation references', async () => {
|
||||
render(<UploadWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
|
||||
fireEvent.change(screen.getByLabelText('上传图片文件'), {
|
||||
target: {
|
||||
files: [createTestFile('普通参考.png')],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toContain(
|
||||
'generate:idle:-:0:-:-:1:普通参考.png',
|
||||
);
|
||||
});
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches file input uploads to spec reference images', async () => {
|
||||
render(<UploadWorkflowHarness />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user