接入画板生成视频功能

新增画板底部生成视频入口、Lovart 风格面板、视频图层渲染与元数据展示。

接入 /api/editor/videos/generations 契约与后端 Ark/VectorEngine 视频任务链路。

统一编辑器生成类泥点配置,并补充 UI 设计图、参考图与生成面板结构测试。

更新编辑器技术方案、生成类面板方案和 Hermes 共享决策/踩坑记录。
This commit is contained in:
2026-06-17 20:47:27 +08:00
parent d1cd300695
commit b2fd5574db
39 changed files with 3390 additions and 238 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,
};
}

View File

@@ -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';
}

View File

@@ -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 });
});
});

View File

@@ -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,
};
}

View File

@@ -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()} />);

View File

@@ -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">

View File

@@ -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('参考图生成规范'),
}),

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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 />);