Image editor: Seedance2.0 refs and audio changes

Adds Seedance 2.0 reference-media constraints and forbids video data URLs; updates docs/decision-log and pitfalls to record the new rules. Introduces typed audio params (type: one-shot|loop, tempo: number|null), generator-specific placeholder styles/avoidance and UI changes for generation panels. Implements related front-end changes (new crop/ raster edit components, many image-editor models, tests and workflow hooks) and server-side contract/handler updates across api-server, shared-contracts, platform-audio, platform-image and vector_engine_audio_generation to support the new submission/asset flow and validations.
This commit is contained in:
2026-06-18 17:31:35 +08:00
parent 6f1ae6caef
commit 9c7b3ad16d
68 changed files with 3262 additions and 1294 deletions
@@ -26,7 +26,7 @@ describe('ImageCanvasBottomToolbarView', () => {
expect(
within(toolbar)
.getByRole('button', { name: '生成工具' })
.getByRole('button', { name: '生成图片' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
@@ -42,14 +42,16 @@ describe('ImageCanvasBottomToolbarView', () => {
within(toolbar).getByRole('button', { name: '生成UI设计图' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '生成音乐' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '文字工具' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成图标素材' }),
);
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
expect(switchTool).toHaveBeenNthCalledWith(3, 'spec');
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
expect(switchTool).toHaveBeenNthCalledWith(6, 'text');
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
});
it('only keeps the select and hand tools visibly pressed', () => {
@@ -87,16 +89,13 @@ describe('ImageCanvasBottomToolbarView', () => {
for (const tool of [
['upload', '上传工具'],
['generate', '生成工具'],
['generate', '生成图片'],
['video', '生成视频'],
['music', '生成音乐'],
['spec', '生成规范'],
['character', '生成角色形象'],
['icon', '生成图标素材'],
['ui-design', '生成UI设计图'],
['text', '文字工具'],
['shape', '形状标注工具'],
['export', '导出工具'],
] as const) {
rerender(renderToolbar(tool[0]));
@@ -1,17 +1,14 @@
import {
ClipboardList,
AppWindow,
Clapperboard,
Download,
ClipboardList,
Grid2X2,
Hand,
ImageIcon,
ImagePlus,
LayoutTemplate,
MousePointer2,
Music,
Shapes,
Sparkles,
Type,
WandSparkles,
UserRound,
} from 'lucide-react';
import type { RefObject } from 'react';
@@ -33,20 +30,28 @@ const canvasTools: Array<{
id: CanvasTool;
label: string;
icon: typeof MousePointer2;
separatorBefore?: boolean;
}> = [
{ id: 'select', label: '选择工具', icon: MousePointer2 },
{ id: 'hand', label: '抓手工具', icon: Hand },
{ id: 'upload', label: '上传工具', icon: ImagePlus },
{ id: 'generate', label: '生成工具', icon: WandSparkles },
{
id: 'generate',
label: '生成图片',
icon: ImageIcon,
separatorBefore: true,
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{ id: 'music', label: '生成音乐', icon: Music },
{ id: 'spec', label: '生成规范', icon: ClipboardList },
{ id: 'character', label: '生成角色形象', icon: Sparkles },
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
{ id: 'ui-design', label: '生成UI设计图', icon: LayoutTemplate },
{ id: 'text', label: '文字工具', icon: Type },
{ id: 'shape', label: '形状标注工具', icon: Shapes },
{ id: 'export', label: '导出工具', icon: Download },
{
id: 'spec',
label: '生成规范',
icon: ClipboardList,
separatorBefore: true,
},
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
@@ -72,43 +77,57 @@ export function ImageCanvasBottomToolbarView({
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{canvasTools.map(({ id, label, icon: Icon }) => {
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
if (isToolbarOptionTool(id)) {
return (
<span
key={id}
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
]
.filter(Boolean)
.join(' ')}
onPointerEnter={() => onOpenToolOptions(id)}
onPointerLeave={() => onCloseToolOptions(id)}
onFocus={() => onOpenToolOptions(id)}
onBlur={() => onCloseToolOptions(id)}
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
<span key={id} className="image-canvas-editor__bottom-tool-group">
{separatorBefore ? (
<span
aria-hidden="true"
className="image-canvas-editor__bottom-toolbar-divider"
/>
) : null}
<span
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
]
.filter(Boolean)
.join(' ')}
onPointerEnter={() => onOpenToolOptions(id)}
onPointerLeave={() => onCloseToolOptions(id)}
onFocus={() => onOpenToolOptions(id)}
onBlur={() => onCloseToolOptions(id)}
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
</span>
);
}
return (
<EditorIconButton
key={id}
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
<span key={id} className="image-canvas-editor__bottom-tool-group">
{separatorBefore ? (
<span
aria-hidden="true"
className="image-canvas-editor__bottom-toolbar-divider"
/>
) : null}
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
);
})}
</div>
@@ -0,0 +1,108 @@
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { X } from 'lucide-react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer, CropExpandPanelState } from './ImageCanvasEditorTypes';
type ImageCanvasCropExpandPanelViewProps = {
panel: CropExpandPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
onSubmit: () => void;
};
const CROP_EXPAND_FIELDS = [
{ key: 'left', label: '左' },
{ key: 'top', label: '上' },
{ key: 'right', label: '右' },
{ key: 'bottom', label: '下' },
] as const;
function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function ImageCanvasCropExpandPanelView({
panel,
sourceLayer,
style,
setCropExpandPanel,
onSubmit,
}: ImageCanvasCropExpandPanelViewProps) {
return (
<form
className="image-canvas-editor__crop-expand-panel"
style={style}
role="dialog"
aria-label="裁扩图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (panel.status !== 'processing') {
onSubmit();
}
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
<span>{sourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭裁扩图片"
title="关闭"
icon={X}
onClick={() => setCropExpandPanel(null)}
/>
</div>
<div className="image-canvas-editor__crop-expand-fields">
{CROP_EXPAND_FIELDS.map(({ key, label }) => (
<label key={key} className="image-canvas-editor__crop-expand-field">
<span>{label}</span>
<PlatformTextField
aria-label={`裁扩${label}`}
value={String(panel.insets[key])}
inputMode="numeric"
size="xs"
density="compact"
onChange={(event) =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
insets: {
...currentPanel.insets,
[key]: event.target.value,
},
}
: currentPanel,
)
}
/>
</label>
))}
</div>
{panel.status === 'failed' ? (
<p className="image-canvas-editor__crop-expand-error" role="alert">
{panel.errorMessage}
</p>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '应用'}
</PlatformActionButton>
</form>
);
}
@@ -13,8 +13,8 @@ import { describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
ImageCanvasEditorView,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
@@ -216,7 +216,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成工具' }),
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
);
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
@@ -343,7 +343,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
const initialComposerTop = Number.parseFloat(
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
.top,
@@ -421,7 +421,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中继续拖动的图片' },
});
@@ -500,7 +500,7 @@ describe('ImageCanvasEditorView generation integration', () => {
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(
@@ -529,7 +529,7 @@ describe('ImageCanvasEditorView generation integration', () => {
it('hides the generation composer when clicking the canvas outside generation controls', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
@@ -546,7 +546,7 @@ describe('ImageCanvasEditorView generation integration', () => {
it('closes the generation composer without removing the placeholder frame', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
@@ -559,7 +559,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张真实生成失败的图' },
});
@@ -586,7 +586,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张需要登录生成的图' },
});
@@ -604,7 +604,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const cases = [
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中的普通图片' },
});
@@ -758,7 +758,7 @@ describe('ImageCanvasEditorView generation integration', () => {
.getAllByRole('button')
.filter((button) => button.getAttribute('aria-label')?.startsWith('生成'))
.map((button) => button.getAttribute('aria-label'));
expect(generationToolLabels).toContain('生成工具');
expect(generationToolLabels).toContain('生成图片');
expect(generationToolLabels).toContain('生成规范');
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
@@ -817,8 +817,10 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
prompt: expect.stringContaining('玩法设计:平台跳跃玩法'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
@@ -885,9 +887,13 @@ describe('ImageCanvasEditorView generation integration', () => {
name: '图标素材规范',
}),
).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
expect(
within(iconSpritesheetPanel).getByRole('textbox', {
name: '素材描述',
}),
).toBeTruthy();
expect(within(iconSpritesheetPanel).queryByText('素材描述 1')).toBeNull();
expect(within(iconSpritesheetPanel).queryByText('素材描述 6')).toBeNull();
expect(
within(iconSpritesheetPanel).getByRole('button', {
name: '生成图片模型 nanobanana2',
@@ -1024,6 +1030,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'icon-gpt-model-1',
priceMudPoints: 12,
});
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
@@ -1068,6 +1075,14 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByLabelText('上传图片文件'),
new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '图标规范.png' },
),
).toBeTruthy();
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
@@ -1139,7 +1154,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中切换后仍保留位置' },
});
@@ -1299,7 +1314,7 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
});
it('expands the icon panel width as new description items are added', async () => {
it('uses one direct text input for icon asset descriptions', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
@@ -1309,23 +1324,26 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1);
expect(iconPanel.style.width).toBe('');
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
).toBeTruthy();
).toBeNull();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
).toBeTruthy();
).toBeNull();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy();
fireEvent.click(
within(iconPanel).getByRole('button', { name: '添加素材描述' }),
fireEvent.change(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
);
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(1);
expect(
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
).toBeNull();
});
it('hides the active generation panel and clears image selection after canvas background focus', async () => {
@@ -1342,7 +1360,7 @@ describe('ImageCanvasEditorView generation integration', () => {
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '发光蘑菇角色' },
});
@@ -1429,8 +1447,10 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
@@ -1476,8 +1496,10 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
prompt: '生成一张武器图标规范展板',
});
await waitFor(() => {
@@ -1804,6 +1826,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-1',
priceMudPoints: 12,
});
render(<ImageCanvasEditorView />);
@@ -1820,11 +1843,12 @@ describe('ImageCanvasEditorView generation integration', () => {
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
).toBeTruthy();
expect(
(within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value,
).toBe('返回按钮');
expect(
(within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value,
).toBe('冻结按钮');
(
within(iconPanel).getByRole('textbox', {
name: '素材描述',
}) as HTMLTextAreaElement
).value,
).toBe('返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮');
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
@@ -1863,32 +1887,12 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'),
).toBeNull();
const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox');
const [
,
,
iconDescription3,
iconDescription4,
iconDescription5,
iconDescription6,
] = iconDescriptionInputs;
expect(iconDescription3).toBeTruthy();
expect(iconDescription4).toBeTruthy();
expect(iconDescription5).toBeTruthy();
expect(iconDescription6).toBeTruthy();
fireEvent.change(iconDescription3!, {
target: { value: '' },
});
fireEvent.change(iconDescription4!, {
target: { value: '' },
});
fireEvent.change(iconDescription5!, {
target: { value: '' },
});
fireEvent.change(iconDescription6!, {
target: { value: '' },
});
fireEvent.change(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{
target: { value: '返回按钮\n设置按钮' },
},
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
@@ -1897,6 +1901,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 12,
});
await waitFor(() => {
@@ -1911,10 +1916,12 @@ describe('ImageCanvasEditorView generation integration', () => {
const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述')).toBeTruthy();
expect(
within(iconInfoPanel).getByText(
(_, element) => element?.textContent === '返回按钮\n设置按钮',
),
).toBeTruthy();
expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
await waitFor(() => {
@@ -2413,8 +2420,8 @@ describe('ImageCanvasEditorView generation integration', () => {
provider: 'VectorEngine',
taskId: 'editor-real-task-2',
});
editEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl',
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZWRhd24tcmVkcmF3',
width: 1024,
height: 1024,
sourceType: 'generated',
@@ -2422,11 +2429,11 @@ describe('ImageCanvasEditorView generation integration', () => {
actualPrompt: '把画面改成黄昏光线',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-edit-1',
taskId: 'editor-real-redraw-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张可修改的生成图' },
});
@@ -2471,41 +2478,45 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
expect(editDialog).toBeTruthy();
const editPrompt = screen.getByLabelText('生成提示词');
const editPrompt = within(editDialog).getByLabelText('重绘提示词');
expect(editPrompt.className).toContain('platform-text-field');
expect(editPrompt.className).toContain(
'image-canvas-editor__generate-prompt',
'image-canvas-editor__quick-edit-prompt',
);
fireEvent.change(editPrompt, {
target: { value: '把画面改成黄昏光线' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('修改中');
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
});
expect(generateEditorImageMock).toHaveBeenLastCalledWith(
expect.objectContaining({
prompt: '把画面改成黄昏光线',
size: '1024x1024',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,ZmFrZS1pbWFnZQ=='],
}),
);
});
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy();
});
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
expect(screen.getByAltText(/画布图片:生成图片 .* 重绘/)).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: / .* /u,
name: / .* /u,
})[0]!,
);
const editedMetadataDialog = screen.getByRole('dialog', {
name: '图片信息',
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
expect(within(editedMetadataDialog).getByText('重绘提示词')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
).toBeTruthy();
@@ -2518,16 +2529,16 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
});
it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
it('keeps the redraw panel open after generation starts while keeping the source preview visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-edit-generating',
title: '修改图片生成中画布',
title: '重绘生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-edit-generating-source',
resourceId: 'resource-edit-generating-source',
title: '待修改图片',
title: '待重绘',
src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
x: 120,
y: 140,
@@ -2547,10 +2558,10 @@ describe('ImageCanvasEditorView generation integration', () => {
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:待修改图片');
const sourceImage = await screen.findByAltText('画布图片:待重绘');
const sourceLayer = sourceImage.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
@@ -2563,21 +2574,19 @@ describe('ImageCanvasEditorView generation integration', () => {
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
fireEvent.change(within(editDialog).getByLabelText('生成提示词'), {
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), {
target: { value: '改成雨夜灯光' },
});
fireEvent.click(within(editDialog).getByRole('button', { name: '修改' }));
fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy();
expect(sourceLayer.className).toContain(
expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy();
expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy();
expect(sourceLayer.className).not.toContain(
'image-canvas-editor__layer--generating',
);
expect(within(sourceLayer).getByRole('status').textContent).toContain(
'修改中',
);
expect(within(editDialog).getByRole('button', { name: '生成中' })).toBeTruthy();
});
it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
@@ -164,10 +164,12 @@ describe('ImageCanvasEditorModel', () => {
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-audio',
mode: 'audio-background-music',
prompt: '森林冒险背景音乐',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundType: 'loop',
soundTempo: 120,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
@@ -189,8 +191,10 @@ describe('ImageCanvasEditorModel', () => {
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-audio',
mode: 'audio-background-music',
prompt: '森林冒险背景音乐',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundType: 'loop',
soundTempo: 120,
generatedLayerId: 'layer-audio',
});
});
@@ -4,13 +4,13 @@ import type {
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasContextMenuState,
CanvasMediaType,
CharacterReferenceImage,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
EditorAssetFolder,
SnapCandidate,
@@ -306,17 +306,10 @@ export function hydrateCanvasGenerationDialog(
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
videoSound: snapshot.videoSound === 'off' ? 'off' : undefined,
soundType:
snapshot.soundType === 'foley' || snapshot.soundType === 'ambient'
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
: snapshot.soundType === 'ui'
? 'ui'
: undefined,
soundTempo:
snapshot.soundTempo === 'slow' || snapshot.soundTempo === 'fast'
? snapshot.soundTempo
: snapshot.soundTempo === 'medium'
? 'medium'
: undefined,
: undefined,
soundTempo: bpmOrNull(snapshot.soundTempo),
makeInstrumental:
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
@@ -455,6 +448,17 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
export function bpmOrNull(value: unknown) {
if (value === null) {
return null;
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const roundedValue = Math.round(value);
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
}
export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
@@ -4,14 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
ImageCanvasMetadataModalViewProps,
} from './ImageCanvasMetadataModalView';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataModalView';
import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView';
import type { ImageCanvasStageViewProps } from './ImageCanvasStageView';
import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -161,10 +159,12 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onDeleteSelectedLayer: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenEditDialog: vi.fn(),
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
onPasteCanvasClipboard: vi.fn(),
onCopyContextLayers: vi.fn(),
onDuplicateContextLayers: vi.fn(),
@@ -222,15 +222,15 @@ describe('ImageCanvasEditorShellView', () => {
/>,
);
expect(
screen.getByRole('region', { name: '图片画布编辑器' }),
).toBeTruthy();
expect(screen.getByRole('region', { name: '图片画布编辑器' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
expect(screen.getByLabelText('画布工作区')).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '图片信息' })).toBeTruthy();
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
const uploadInput = screen.getByLabelText(
'上传图片文件',
) as HTMLInputElement;
fireEvent.change(uploadInput);
expect(uploadInput.multiple).toBe(true);
@@ -107,10 +107,7 @@ export type CanvasTool =
| 'spec'
| 'character'
| 'icon'
| 'ui-design'
| 'text'
| 'shape'
| 'export';
| 'ui-design';
export type SidebarPanel = 'assets' | 'layers';
@@ -177,8 +174,9 @@ export type GenerateDialogState = {
videoDurationSeconds?: 4 | 5;
videoMode?: 'std';
videoSound?: 'off';
soundType?: 'ui' | 'foley' | 'ambient';
soundTempo?: 'slow' | 'medium' | 'fast';
soundType?: 'one-shot' | 'loop';
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏。
soundTempo?: number | null;
makeInstrumental?: boolean;
aspectRatio?: string;
imageSize?: string;
@@ -239,6 +237,7 @@ export type CanvasContextMenuState =
};
export type QuickEditPanelState = {
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
size: string;
@@ -247,6 +246,18 @@ export type QuickEditPanelState = {
errorMessage?: string;
};
export type CropExpandPanelState = {
sourceLayerId: string;
insets: {
left: string;
top: string;
right: string;
bottom: string;
};
status: 'idle' | 'processing' | 'failed';
errorMessage?: string;
};
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
@@ -15,9 +15,9 @@ import { describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
AuthUiContext,
ImageCanvasEditorView,
createAuthValue,
dispatchPointerEvent,
ImageCanvasEditorView,
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
@@ -437,8 +437,7 @@ describe('ImageCanvasEditorView', () => {
).toBe(true);
});
it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
it('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => {
render(<ImageCanvasEditorView />);
const canvasImage = screen.getByAltText('画布图片:拼图素材');
@@ -455,7 +454,7 @@ describe('ImageCanvasEditorView', () => {
clientY: 120,
});
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
const cropButton = screen.getByRole('button', { name: '裁扩按钮' });
fireEvent.pointerDown(cropButton, {
button: 0,
pointerId: 2,
@@ -464,7 +463,7 @@ describe('ImageCanvasEditorView', () => {
});
fireEvent.click(cropButton);
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
@@ -505,7 +504,7 @@ describe('ImageCanvasEditorView', () => {
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
@@ -553,7 +552,15 @@ describe('ImageCanvasEditorView', () => {
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
});
it('deletes the selected layer from the floating toolbar', () => {
it('downloads the selected layer from the floating toolbar', () => {
let downloadName = '';
let downloadHref = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
render(<ImageCanvasEditorView />);
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
@@ -567,9 +574,11 @@ describe('ImageCanvasEditorView', () => {
clientY: 120,
},
);
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(downloadName).toBe('拼图素材.png');
expect(downloadHref).toContain('/creation-type-references/puzzle.webp');
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
@@ -823,7 +832,7 @@ describe('ImageCanvasEditorView', () => {
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
@@ -1235,30 +1244,23 @@ describe('ImageCanvasEditorView', () => {
});
it('switches tools and restores the previous tool after holding Space', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
});
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
expect(handTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
});
it('switches away from hand tool from the bottom toolbar', async () => {
@@ -1269,15 +1271,15 @@ describe('ImageCanvasEditorView', () => {
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
await user.click(selectTool);
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('false');
});
@@ -1355,12 +1357,12 @@ describe('ImageCanvasEditorView', () => {
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
await user.click(textTool);
await user.click(handTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('true');
expect(
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
).toBeNull();
@@ -1,27 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
import {
createEditorAsset,
createEditorProjectResource,
} from '../../services/image-editor/editorProjectClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type {
AssetPointerDragState,
CanvasGenerationDialogState,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
CanvasViewport,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useCanvasHistory } from './useCanvasHistory';
import {
useImageCanvasAssetCanvasBridge,
useImageCanvasAssetLayerCleanup,
} from './useImageCanvasAssetCanvasBridge';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
@@ -30,7 +34,6 @@ import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersist
import { useImageCanvasStageController } from './useImageCanvasStageController';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
@@ -217,7 +220,6 @@ export function ImageCanvasEditorView() {
generateDialog,
setGenerateDialog,
generateDialogRef,
inactiveGenerateDialogs,
setInactiveGenerateDialogs,
inactiveGenerateDialogsRef,
activeCanvasGenerationDialog,
@@ -243,7 +245,7 @@ export function ImageCanvasEditorView() {
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
[generateDialogRef, inactiveGenerateDialogsRef],
);
const canvasHistorySetters = useMemo(
() => ({
@@ -254,7 +256,14 @@ export function ImageCanvasEditorView() {
setSelectedLayerId,
setSelectedLayerIds,
}),
[],
[
setGenerateDialog,
setInactiveGenerateDialogs,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
setViewport,
],
);
const canvasHistoryResetters = useMemo(
() => ({
@@ -277,20 +286,23 @@ export function ImageCanvasEditorView() {
resetters: canvasHistoryResetters,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
const selectSingleLayer = useCallback((layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
if (layerId) {
setGenerateDialog((currentDialog) =>
isCanvasGenerationComposerVisible(currentDialog)
? {
...currentDialog,
composerOpen: currentDialog.generatedLayerId === layerId,
}
: currentDialog,
);
}
}, []);
const selectSingleLayer = useCallback(
(layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
if (layerId) {
setGenerateDialog((currentDialog) =>
isCanvasGenerationComposerVisible(currentDialog)
? {
...currentDialog,
composerOpen: currentDialog.generatedLayerId === layerId,
}
: currentDialog,
);
}
},
[setGenerateDialog],
);
const persistGeneratedAsset = useCallback(
(layer: CanvasLayer) => {
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
@@ -392,7 +404,14 @@ export function ImageCanvasEditorView() {
},
restoreCanvasGenerationDialogs,
}),
[restoreCanvasGenerationDialogs, selectSingleLayer],
[
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayers,
setProjectRenameValue,
setProjectTitle,
setViewport,
],
);
const { projectId, appendCanvasLayersWithResources } =
useImageCanvasProjectPersistence({
@@ -404,6 +423,37 @@ export function ImageCanvasEditorView() {
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
});
const persistUpdatedLayerResource = useCallback(
(layer: CanvasLayer) => {
if (!projectId) {
return;
}
void createEditorProjectResource(projectId, {
imageSrc: layer.src,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
sourceResourceId: layer.sourceResourceId,
}).then((resource) => {
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
}
: currentLayer,
),
);
});
},
[projectId],
);
const {
assetExportStatus,
isExportingAssets,
@@ -417,7 +467,6 @@ export function ImageCanvasEditorView() {
const {
uploadInputRef,
uploadTarget,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
@@ -445,6 +494,7 @@ export function ImageCanvasEditorView() {
canvasSize,
viewport,
setViewport,
setLayers,
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
@@ -464,12 +514,14 @@ export function ImageCanvasEditorView() {
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
});
const {
quickEditPanel,
@@ -491,8 +543,10 @@ export function ImageCanvasEditorView() {
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openEditDialog,
openQuickEditPanel,
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickCharacterReferenceFromLayer,
@@ -541,7 +595,6 @@ export function ImageCanvasEditorView() {
deleteContextLayers,
exportContextLayer,
deleteLayerById,
deleteSelectedLayer,
groupSelectedLayers,
} = useImageCanvasLayerCommands({
layers,
@@ -701,7 +754,7 @@ export function ImageCanvasEditorView() {
requestUpload('asset');
return;
}
if (switchGenerationTool(tool)) {
if (switchGenerationTool(tool)) {
return;
}
setActiveTool(tool);
@@ -844,10 +897,12 @@ export function ImageCanvasEditorView() {
},
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onDeleteSelectedLayer: deleteSelectedLayer,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenEditDialog: openEditDialog,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
onDownloadLayer: exportLayerImage,
onPasteCanvasClipboard: pasteCanvasClipboard,
onCopyContextLayers: copyContextLayers,
onDuplicateContextLayers: duplicateContextLayers,
@@ -2,11 +2,11 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
createRef,
useState,
type ComponentProps,
createRef,
type Dispatch,
type SetStateAction,
useState,
} from 'react';
import { describe, expect, it, vi } from 'vitest';
@@ -47,6 +47,9 @@ function createComposerProps(
quickEditPanel: null,
quickEditSourceLayer: null,
quickEditPanelStyle: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
cropExpandPanelStyle: null,
quickEditSizeOptions: ['1024x1024'],
quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }],
characterAnimationPanel: null,
@@ -60,6 +63,12 @@ function createComposerProps(
typeof ImageCanvasGenerationComposerView
>['quickEditPanel']
>(),
setCropExpandPanel:
mockStateSetter<
ComponentProps<
typeof ImageCanvasGenerationComposerView
>['cropExpandPanel']
>(),
setCharacterAnimationPanel:
mockStateSetter<
ComponentProps<
@@ -81,11 +90,11 @@ function createComposerProps(
onSubmitImageGeneration: vi.fn(),
onSubmitIconSpritesheetGeneration: vi.fn(),
onSubmitQuickEdit: vi.fn(),
onSubmitCropExpand: vi.fn(),
onSubmitCharacterAnimation: vi.fn(),
onCloseGenerateComposer: vi.fn(),
onUpdateSpecFormValue: vi.fn(),
onUpdateIconDescription: vi.fn(),
onAddIconDescription: vi.fn(),
onUpdateIconDescriptionText: vi.fn(),
onUpdateCharacterAnimationDuration: vi.fn(),
onRememberImageModel: vi.fn(),
...overrides,
@@ -403,31 +412,75 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
});
it('生成游戏音效面板展示 sound、type、tempo 并在按钮内显示泥点', () => {
it('生成游戏音效面板把类型和 BPM 合并到一个无标题参数按钮', () => {
const onSubmitImageGeneration = vi.fn();
renderComposer(
{
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'ui',
soundTempo: 'medium',
},
{ onSubmitImageGeneration },
);
soundType: 'one-shot',
soundTempo: null,
});
return (
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, { onSubmitImageGeneration })}
setGenerateDialog={
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
/>
<output aria-label="当前音效类型">{dialog.soundType}</output>
<output aria-label="当前BPM">{dialog.soundTempo ?? 'null'}</output>
</>
);
}
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByRole('textbox', { name: 'sound提示词' })).toBeTruthy();
expect(within(panel).getByRole('button', { name: 'type UI' })).toBeTruthy();
expect(within(panel).getByRole('button', { name: 'tempo 中速' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '音效参数 单次' }),
).toBeTruthy();
expect(within(panel).queryByRole('button', { name: /type/u })).toBeNull();
expect(within(panel).queryByRole('button', { name: /tempo/u })).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
'生成10泥点',
);
fireEvent.click(
within(panel).getByRole('button', { name: '音效参数 单次' }),
);
const optionPanel = screen.getByRole('menu', { name: '音效参数选项' });
fireEvent.click(within(optionPanel).getByRole('button', { name: '循环' }));
fireEvent.change(within(optionPanel).getByLabelText('BPM数值'), {
target: { value: '120' },
});
expect(screen.getByLabelText('当前音效类型').textContent).toBe('loop');
expect(screen.getByLabelText('当前BPM').textContent).toBe('120');
expect(
screen.getByRole('button', { name: '音效参数 循环·120BPM' }),
).toBeTruthy();
fireEvent.change(within(optionPanel).getByLabelText('BPM拖拉条'), {
target: { value: '88' },
});
expect(screen.getByLabelText('当前BPM').textContent).toBe('88');
expect(
screen.getByRole('button', { name: '音效参数 循环·88BPM' }),
).toBeTruthy();
fireEvent.submit(panel);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'audio-sound-effect' }),
expect.objectContaining({
mode: 'audio-sound-effect',
soundType: 'loop',
soundTempo: 88,
}),
);
});
@@ -11,39 +11,41 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
import {
EDITOR_VIDEO_DURATION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SPEC_TYPE_LABEL,
calculateEditorVideoPrice,
} from './ImageCanvasGenerationModel';
import type {
CharacterAnimationPanelState,
CanvasLayer,
CharacterAnimationPanelState,
CropExpandPanelState,
GenerateDialogState,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateEditorVideoPrice,
EDITOR_GENERATION_MUD_POINT_CONFIG,
EDITOR_VIDEO_DURATION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
@@ -68,6 +70,9 @@ type ImageCanvasGenerationComposerViewProps = {
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditPanelStyle: CSSProperties | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
cropExpandPanelStyle: CSSProperties | null;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
characterAnimationPanel: CharacterAnimationPanelState | null;
@@ -76,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = {
characterAnimationPrice: number;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
setCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
>;
@@ -94,11 +100,11 @@ type ImageCanvasGenerationComposerViewProps = {
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => void;
onCloseGenerateComposer: () => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescription: (index: number, value: string) => void;
onAddIconDescription: () => void;
onUpdateIconDescriptionText: (value: string) => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
onRememberImageModel: (model: string) => void;
onSpecMenuPointerEnter?: () => void;
@@ -166,7 +172,11 @@ function VideoReferenceChip({
}) {
const mediaType = reference.mediaType ?? 'image';
const labelPrefix =
mediaType === 'video' ? '参考视频' : mediaType === 'audio' ? '参考音频' : '参考图';
mediaType === 'video'
? '参考视频'
: mediaType === 'audio'
? '参考音频'
: '参考图';
const label = reference.label || `${labelPrefix}${index + 1}`;
const icon =
mediaType === 'video' ? (
@@ -208,10 +218,7 @@ function VideoOptionChoice({
return (
<button
type="button"
className={[
'image-canvas-editor__option-popover-choice',
className,
]
className={['image-canvas-editor__option-popover-choice', className]
.filter(Boolean)
.join(' ')}
aria-label={ariaLabel}
@@ -258,8 +265,9 @@ function ImageCanvasVideoGenerationComposerView({
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
const currentModel =
EDITOR_VIDEO_MODEL_OPTIONS.find((item) => item.value === dialog.videoModel) ??
EDITOR_VIDEO_MODEL_OPTIONS[0];
EDITOR_VIDEO_MODEL_OPTIONS.find(
(item) => item.value === dialog.videoModel,
) ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
const supportsReferences = isSeedanceVideoModel(currentModel.value);
const price = calculateEditorVideoPrice(resolution, durationSeconds);
const isGenerating = dialog.status === 'generating';
@@ -270,7 +278,11 @@ function ImageCanvasVideoGenerationComposerView({
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null || isGenerationReferenceMenuOpen,
boundaryRefs: [paramsButtonRef, modelButtonRef, generationReferenceButtonRef],
boundaryRefs: [
paramsButtonRef,
modelButtonRef,
generationReferenceButtonRef,
],
onDismiss: closeVideoFloatingPanels,
});
@@ -344,7 +356,9 @@ function ImageCanvasVideoGenerationComposerView({
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateVideoDialog({ prompt: event.target.value })}
onChange={(event) =>
updateVideoDialog({ prompt: event.target.value })
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
@@ -369,7 +383,10 @@ function ImageCanvasVideoGenerationComposerView({
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="视频参数选项"
placement="top-start"
style={buildPortalMenuStyle(paramsButtonRef.current, 'above')}
style={buildPortalMenuStyle(
paramsButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
@@ -474,7 +491,10 @@ function ImageCanvasVideoGenerationComposerView({
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
label="视频模型选项"
placement="top-start"
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
style={buildPortalMenuStyle(
modelButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
@@ -585,16 +605,35 @@ function ImageCanvasVideoGenerationComposerView({
}
const SOUND_TYPE_OPTIONS = [
{ label: 'UI', value: 'ui' },
{ label: '拟音', value: 'foley' },
{ label: '环境', value: 'ambient' },
{ label: '单次', value: 'one-shot' },
{ label: '循环', value: 'loop' },
] as const;
const SOUND_BPM_MIN = 1;
const SOUND_BPM_MAX = 300;
const SOUND_BPM_SLIDER_FALLBACK = 120;
const SOUND_TEMPO_OPTIONS = [
{ label: '慢速', value: 'slow' },
{ label: '中速', value: 'medium' },
{ label: '快速', value: 'fast' },
] as const;
function clampSoundBpm(value: number) {
return Math.min(
SOUND_BPM_MAX,
Math.max(SOUND_BPM_MIN, Math.round(value)),
);
}
function parseSoundBpmInput(value: string) {
const normalizedValue = value.trim();
if (!normalizedValue) {
return null;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? clampSoundBpm(parsedValue) : null;
}
function formatSoundEffectOptionLabel(
typeLabel: string,
soundTempo: number | null | undefined,
) {
return soundTempo ? `${typeLabel}·${soundTempo}BPM` : typeLabel;
}
function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
return {
@@ -622,26 +661,28 @@ function ImageCanvasAudioGenerationComposerView({
) => CSSProperties;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const [openPanel, setOpenPanel] = useState<'type' | 'tempo' | null>(null);
const typeButtonRef = useRef<HTMLButtonElement | null>(null);
const tempoButtonRef = useRef<HTMLButtonElement | null>(null);
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
const currentType =
SOUND_TYPE_OPTIONS.find((option) => option.value === dialog.soundType) ??
SOUND_TYPE_OPTIONS[0];
const currentTempo =
SOUND_TEMPO_OPTIONS.find((option) => option.value === dialog.soundTempo) ??
SOUND_TEMPO_OPTIONS[1];
typeof dialog.soundTempo === 'number' ? dialog.soundTempo : null;
const soundOptionLabel = formatSoundEffectOptionLabel(
currentType.label,
currentTempo,
);
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const cost = isSoundEffect
? EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect
: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic;
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [typeButtonRef, tempoButtonRef],
onDismiss: () => setOpenPanel(null),
isOpen: isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
});
const updateAudioDialog = (patch: Partial<GenerateDialogState>) => {
@@ -655,6 +696,10 @@ function ImageCanvasAudioGenerationComposerView({
);
};
const updateSoundTempo = (nextTempo: number | null) => {
updateAudioDialog({ soundTempo: nextTempo });
};
return (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio"
@@ -675,7 +720,9 @@ function ImageCanvasAudioGenerationComposerView({
value={dialog.prompt}
disabled={isGenerating}
placeholder={
isSoundEffect ? '描述需要生成的游戏音效' : '描述需要生成的游戏背景音乐'
isSoundEffect
? '描述需要生成的游戏音效'
: '描述需要生成的游戏背景音乐'
}
size="sm"
density="compact"
@@ -695,97 +742,77 @@ function ImageCanvasAudioGenerationComposerView({
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={typeButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`type ${currentType.label}`}
aria-expanded={openPanel === 'type'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() =>
setOpenPanel((currentPanel) =>
currentPanel === 'type' ? null : 'type',
)
}
>
type {currentType.label}
</PlatformInlineOptionButton>
{openPanel === 'type'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="音效 type 选项"
placement="top-start"
style={buildPortalMenuStyle(typeButtonRef.current, 'above')}
>
<div className="image-canvas-editor__option-popover-items">
{SOUND_TYPE_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentType.value === option.value}
disabled={isGenerating}
ariaLabel={`type ${option.label}`}
onClick={() =>
updateAudioDialog({ soundType: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--audio-tempo">
<PlatformInlineOptionButton
ref={tempoButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`tempo ${currentTempo.label}`}
aria-expanded={openPanel === 'tempo'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() =>
setOpenPanel((currentPanel) =>
currentPanel === 'tempo' ? null : 'tempo',
)
}
>
tempo {currentTempo.label}
</PlatformInlineOptionButton>
{openPanel === 'tempo'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="音效 tempo 选项"
placement="top-start"
style={buildPortalMenuStyle(
tempoButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items">
{SOUND_TEMPO_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentTempo.value === option.value}
disabled={isGenerating}
ariaLabel={`tempo ${option.label}`}
onClick={() =>
updateAudioDialog({ soundTempo: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
</>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效参数 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效参数选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_TYPE_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentType.value === option.value}
disabled={isGenerating}
ariaLabel={option.label}
onClick={() =>
updateAudioDialog({ soundType: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
<div className="image-canvas-editor__audio-bpm-control">
<input
type="range"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? SOUND_BPM_SLIDER_FALLBACK}
disabled={isGenerating}
aria-label="BPM拖拉条"
onChange={(event) =>
updateSoundTempo(
clampSoundBpm(Number(event.target.value)),
)
}
/>
<input
type="number"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? ''}
placeholder="BPM"
disabled={isGenerating}
aria-label="BPM数值"
onChange={(event) =>
updateSoundTempo(
parseSoundBpmInput(event.target.value),
)
}
/>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<PlatformActionButton
type="submit"
@@ -835,6 +862,9 @@ export function ImageCanvasGenerationComposerView({
quickEditPanel,
quickEditSourceLayer,
quickEditPanelStyle,
cropExpandPanel,
cropExpandSourceLayer,
cropExpandPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
@@ -843,6 +873,7 @@ export function ImageCanvasGenerationComposerView({
characterAnimationPrice,
setGenerateDialog,
setQuickEditPanel,
setCropExpandPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
@@ -859,11 +890,11 @@ export function ImageCanvasGenerationComposerView({
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onCloseGenerateComposer,
onUpdateSpecFormValue,
onUpdateIconDescription,
onAddIconDescription,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
onRememberImageModel,
onSpecMenuPointerEnter,
@@ -1041,8 +1072,7 @@ export function ImageCanvasGenerationComposerView({
buildPortalMenuStyle={buildPortalMenuStyle}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onUpdateIconDescription={onUpdateIconDescription}
onAddIconDescription={onAddIconDescription}
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitIconSpritesheetGeneration}
/>
@@ -1075,7 +1105,8 @@ export function ImageCanvasGenerationComposerView({
) : null}
{quickEditPanel &&
quickEditPanel.status !== 'generating' &&
(quickEditPanel.mode === 'redraw' ||
quickEditPanel.status !== 'generating') &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
@@ -1089,6 +1120,18 @@ export function ImageCanvasGenerationComposerView({
/>
) : null}
{cropExpandPanel &&
cropExpandSourceLayer &&
cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
sourceLayer={cropExpandSourceLayer}
style={cropExpandPanelStyle}
setCropExpandPanel={setCropExpandPanel}
onSubmit={onSubmitCropExpand}
/>
) : null}
{characterAnimationPanel &&
characterAnimationSourceLayer &&
characterAnimationPanelStyle ? (
@@ -8,28 +8,28 @@ import type {
import {
appendCharacterReference,
appendGenerationReference,
appendIconDescriptionToDialog,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createQuickEditPanelDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
formatIconDescriptionsText,
hideGeneratedLayerComposerAfterBlur,
parseIconDescriptionsText,
updateCharacterAnimationDurationPanel,
updateIconDescriptionInDialog,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import { ICON_DESCRIPTION_LIMIT } from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -120,6 +120,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮',
iconSpecReference: null,
iconDescriptions: [
'返回按钮',
@@ -193,8 +194,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'audio-sound-effect',
status: 'idle',
composerOpen: true,
soundType: 'ui',
soundTempo: 'medium',
soundType: 'one-shot',
soundTempo: null,
placeholder: {
x: 270,
y: 300,
@@ -234,6 +235,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
sourceLayerId: 'layer-source',
});
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
@@ -303,6 +305,14 @@ describe('ImageCanvasGenerationDialogModel', () => {
it('routes picked canvas references to spec, image, video, and UI design fields', () => {
const sourceLayer = createLayer({ title: '参考图' });
const videoLayer = createLayer({
id: 'layer-video',
title: '动作参考',
src: 'https://signed.example.com/reference.mp4',
mediaType: 'video',
objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
});
expect(
appendGenerationReference(
{
@@ -331,6 +341,41 @@ describe('ImageCanvasGenerationDialogModel', () => {
).toMatchObject({
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(
{
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0-fast',
generationReferences: [],
},
videoLayer,
),
).toMatchObject({
generationReferences: [
{
label: '动作参考',
mediaType: 'video',
objectKey:
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
},
],
});
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: [],
};
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog);
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
status: 'idle',
};
expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog);
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
@@ -351,6 +396,57 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('limits Seedance canvas-picked references by media type', () => {
const videoDialog: GenerateDialogState = {
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0',
generationReferences: Array.from({ length: 3 }, (_, index) => ({
id: `video-${index}`,
label: `视频${index + 1}`,
src: `generated-character-drafts/video-${index}.mp4`,
mediaType: 'video',
})),
};
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'video-extra',
title: '额外视频',
mediaType: 'video',
src: 'generated-character-drafts/video-extra.mp4',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
],
});
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'audio-first',
title: '旁白',
mediaType: 'audio',
src: 'generated-character-drafts/audio.mp3',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
{ label: '旁白', mediaType: 'audio' },
],
});
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
@@ -387,31 +483,22 @@ describe('ImageCanvasGenerationDialogModel', () => {
errorMessage: '描述错误',
iconDescriptions: ['旧描述'],
};
expect(updateIconDescriptionInDialog(iconDialog, 0, '新描述')).toEqual(
expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
iconDescriptions: ['新描述'],
}),
);
expect(appendIconDescriptionToDialog(iconDialog)).toEqual(
expect.objectContaining({
iconDescriptions: ['旧描述', ''],
}),
);
expect(
appendIconDescriptionToDialog({
...iconDialog,
iconDescriptions: Array.from(
{ length: ICON_DESCRIPTION_LIMIT },
(_, index) => `图标${index}`,
),
}),
).toEqual(
expect.objectContaining({
iconDescriptions: expect.arrayContaining(['图标0']),
prompt: '剑\n盾、药水',
iconDescriptions: ['剑', '盾', '药水'],
}),
);
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
'剑',
'盾',
'药水',
'宝箱',
'钥匙',
]);
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
});
it('updates character animation duration and composer visibility', () => {
@@ -10,9 +10,9 @@ import type {
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
AUDIO_FRAME_DISPLAY_SIZE,
AUDIO_FRAME_ORIGINAL_SIZE,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
@@ -21,7 +21,6 @@ import {
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
@@ -34,6 +33,11 @@ import {
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
audio: 3,
} as const;
function getViewportWorldCenter({
canvasSize,
@@ -57,11 +61,43 @@ function resetFailedGenerationDialog(dialog: GenerateDialogState) {
};
}
export function parseIconDescriptionsText(text: string): string[] {
return text
.split(/[\r\n,;/|]+/u)
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
}
export function formatIconDescriptionsText(descriptions: string[]): string {
return descriptions.join('\n');
}
function isSeedanceVideoModel(model: string | undefined) {
const resolvedModel = model ?? DEFAULT_VIDEO_MODEL;
return resolvedModel === 'seedance2.0' || resolvedModel === 'seedance2.0-fast';
}
function getReferenceMediaType(layer: CanvasLayer) {
return layer.mediaType === 'video' || layer.mediaType === 'audio'
? layer.mediaType
: 'image';
}
function appendLimitedSeedanceCanvasReference(
references: NonNullable<GenerateDialogState['generationReferences']>,
layer: CanvasLayer,
) {
const mediaType = getReferenceMediaType(layer);
const currentCount = references.filter(
(reference) => (reference.mediaType ?? 'image') === mediaType,
).length;
if (currentCount >= VIDEO_REFERENCE_LIMITS[mediaType]) {
return references;
}
return [...references, createCanvasLayerReference(layer)];
}
function resolveImageDimensionDefaults(imageModel: string) {
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
@@ -178,7 +214,7 @@ export function createIconGenerationDialogDraft({
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
return {
mode: 'icon',
prompt: '',
prompt: formatIconDescriptionsText(DEFAULT_ICON_DESCRIPTIONS),
status: 'idle',
composerOpen: true,
iconSpecReference: null,
@@ -241,8 +277,8 @@ export function createSoundEffectGenerationDialogDraft({
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'ui',
soundTempo: 'medium',
soundType: 'one-shot',
soundTempo: null,
placeholder: {
x: worldCenter.x - AUDIO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - AUDIO_FRAME_DISPLAY_SIZE.height / 2,
@@ -328,6 +364,7 @@ export function createQuickEditPanelDraft(
sourceLayer: CanvasLayer,
): QuickEditPanelState {
return {
mode: 'quick-edit',
sourceLayerId: sourceLayer.id,
prompt: '',
size: formatImageSizeValue(
@@ -339,6 +376,19 @@ export function createQuickEditPanelDraft(
};
}
export function createRedrawPanelDraft(
sourceLayer: CanvasLayer,
): QuickEditPanelState {
return {
...createQuickEditPanelDraft(sourceLayer),
mode: 'redraw',
prompt:
sourceLayer.prompt?.trim() ||
sourceLayer.generationInputs?.fields[0]?.value?.trim() ||
'',
};
}
export function createCharacterAnimationPanelDraft(
layer: CanvasLayer,
): CharacterAnimationPanelState | null {
@@ -360,7 +410,7 @@ export function assignCharacterSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character'
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
@@ -373,7 +423,7 @@ export function appendCharacterReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character'
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
@@ -390,6 +440,9 @@ export function appendGenerationReference(
layer: CanvasLayer,
): GenerateDialogState | null {
if (dialog?.mode === 'spec') {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
specReference: createCanvasLayerReference(layer),
@@ -399,13 +452,26 @@ export function appendGenerationReference(
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
}
return dialog?.mode === 'generate' || dialog?.mode === 'video'
if (dialog?.mode === 'generate') {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
};
}
return dialog?.mode === 'video'
? {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
generationReferences: appendLimitedSeedanceCanvasReference(
dialog.generationReferences ?? [],
layer,
),
composerOpen: true,
}
: dialog;
@@ -462,39 +528,19 @@ export function updateSpecFormDialogValue(
};
}
export function updateIconDescriptionInDialog(
export function updateIconDescriptionsTextInDialog(
dialog: GenerateDialogState | null,
index: number,
value: string,
): GenerateDialogState | null {
return dialog?.mode === 'icon'
? {
...resetFailedGenerationDialog(dialog),
iconDescriptions: (
dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS
).map((description, descriptionIndex) =>
descriptionIndex === index ? value : description,
),
prompt: value,
iconDescriptions: parseIconDescriptionsText(value),
}
: dialog;
}
export function appendIconDescriptionToDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
if (dialog?.mode !== 'icon') {
return dialog;
}
const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
if (descriptions.length >= ICON_DESCRIPTION_LIMIT) {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
iconDescriptions: [...descriptions, ''],
};
}
export function updateCharacterAnimationDurationPanel(
panel: CharacterAnimationPanelState | null,
frameCountValue: string,
@@ -157,6 +157,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 12,
},
iconResults: [
{
@@ -1,18 +1,18 @@
import type {
EditorAudioGenerationResult,
EditorIconSpritesheetGenerationResult,
EditorIconSpritesheetIconResult,
EditorImageGenerationResult,
EditorAudioGenerationResult,
EditorVideoGenerationResult,
} from '../../services/image-editor/editorProjectClient';
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel';
import type {
CanvasGenerationInputs,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
@@ -33,6 +33,7 @@ type QuickEditResultLayerOptions = {
generatedIndex: number;
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
mode?: 'quick-edit' | 'redraw';
};
type IconSpritesheetResultLayerOptions = {
@@ -160,6 +161,7 @@ export function createQuickEditResultLayer({
generatedIndex,
sourceLayer,
generationInputs,
mode = 'quick-edit',
}: QuickEditResultLayerOptions): CanvasLayer {
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
@@ -176,7 +178,7 @@ export function createQuickEditResultLayer({
{
id: `layer-quick-edit-${generatedIndex}`,
resourceId: `local-resource-quick-edit-${generatedIndex}`,
title: `${sourceLayer.title} 快速编辑`,
title: `${sourceLayer.title} ${mode === 'redraw' ? '重绘' : '快速编辑'}`,
src: generated.imageSrc,
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
@@ -1,24 +1,25 @@
import { describe, expect, it } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
DEFAULT_IMAGE_MODEL,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditModelOptions,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
DEFAULT_IMAGE_MODEL,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
describe('ImageCanvasGenerationModel', () => {
it('builds user-facing generation input snapshots instead of backend prompts', () => {
@@ -71,8 +72,7 @@ describe('ImageCanvasGenerationModel', () => {
}),
).toEqual({
fields: [
{ title: '素材描述 1', value: '返回按钮' },
{ title: '素材描述 2', value: '设置按钮' },
{ title: '素材描述', value: '返回按钮\n设置按钮' },
],
references: [
{ title: '图标素材规范', label: '图标规范', src: '/icon-spec.png' },
@@ -166,6 +166,15 @@ describe('ImageCanvasGenerationModel', () => {
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'one-shot', null),
).toEqual({
fields: [
{ title: 'sound', value: '金币掉落叮当声' },
{ title: 'type', value: 'one-shot' },
],
references: [],
});
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
@@ -3,6 +3,7 @@ import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputField,
@@ -13,7 +14,6 @@ import type {
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
@@ -50,10 +50,6 @@ export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
export const ICON_COMPOSER_MIN_WIDTH_REM = 28;
export const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4;
export const DEFAULT_ICON_DESCRIPTIONS = [
'返回按钮',
'设置按钮',
@@ -405,13 +401,15 @@ export function buildVideoGenerationInputs(
export function buildSoundEffectGenerationInputs(
sound: string,
soundType: string,
tempo: string,
tempo: number | null | undefined,
): CanvasGenerationInputs {
return {
fields: [
...createGenerationInputField('sound', sound),
...createGenerationInputField('type', soundType),
...createGenerationInputField('tempo', tempo),
...(tempo
? createGenerationInputField('tempo', `${tempo}BPM`)
: []),
],
references: [],
};
@@ -509,10 +507,12 @@ export function buildIconGenerationInputs(
specReference: CharacterReferenceImage,
): CanvasGenerationInputs {
return {
fields: iconDescriptions.map((description, index) => ({
title: `素材描述 ${index + 1}`,
value: description,
})),
fields: [
{
title: '素材描述',
value: iconDescriptions.join('\n'),
},
],
references: [
{
title: '图标素材规范',
@@ -524,7 +524,7 @@ export function buildIconGenerationInputs(
}
export function buildEditGenerationInputs(
title: '修改要求' | '快速编辑提示词',
title: '修改要求' | '快速编辑提示词' | '重绘提示词',
prompt: string,
sourceLayer: CanvasLayer,
): CanvasGenerationInputs {
@@ -257,7 +257,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
it('builds icon spritesheet plans with trimmed descriptions and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '',
prompt: ' 返回按钮 \n\n设置按钮',
status: 'idle',
imageModel: 'gpt-image-2',
aspectRatio: '3:2',
@@ -267,7 +267,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
label: '图标规范',
src: 'data:image/png;base64,spec',
},
iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
iconDescriptions: ['旧返回', '旧设置'],
});
expect(plan).toEqual({
@@ -279,12 +279,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'gpt-image-2',
aspectRatio: '3:2',
imageSize: '2K',
priceMudPoints: 12,
},
generationInputs: {
fields: [
{ title: '素材描述 1', value: '返回按钮' },
{ title: '素材描述 2', value: '设置按钮' },
],
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标素材规范',
@@ -545,8 +543,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
mode: 'audio-sound-effect',
prompt: ' 金币掉落叮当声 ',
status: 'idle',
soundType: 'ui',
soundTempo: 'fast',
soundType: 'loop',
soundTempo: 120,
},
layers: [],
nextGeneratedIndex: 4,
@@ -558,8 +556,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: '金币掉落叮当声',
input: {
sound: '金币掉落叮当声',
type: 'ui',
tempo: 'fast',
type: 'loop',
tempo: 120,
priceMudPoints: 10,
},
result: {
@@ -567,8 +565,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
generationInputs: {
fields: [
{ title: 'sound', value: '金币掉落叮当声' },
{ title: 'type', value: 'ui' },
{ title: 'tempo', value: 'fast' },
{ title: 'type', value: 'loop' },
{ title: 'tempo', value: '120BPM' },
],
references: [],
},
@@ -576,6 +574,38 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('keeps game sound effect BPM unset as null by default', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '按钮确认短促音',
status: 'idle',
soundType: 'one-shot',
soundTempo: null,
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toMatchObject({
kind: 'audio',
audioKind: 'sound-effect',
input: {
sound: '按钮确认短促音',
type: 'one-shot',
tempo: null,
},
result: {
generationInputs: {
fields: [
{ title: 'sound', value: '按钮确认短促音' },
{ title: 'type', value: 'one-shot' },
],
},
},
});
});
it('builds game background music audio submission plans with instrumental fixed to true', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -1,6 +1,6 @@
import type {
EditorCharacterAnimationGenerationInput,
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageGenerationInput,
EditorSoundEffectGenerationInput,
@@ -13,22 +13,10 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
CHARACTER_ANIMATION_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildIconGenerationInputs,
buildBackgroundMusicGenerationInputs,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
@@ -37,7 +25,20 @@ import {
buildVideoGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
ICON_DESCRIPTION_LIMIT,
resolveCharacterAnimationSourceImageSrc,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
type ImageGenerationSubmissionOptions = {
@@ -338,8 +339,8 @@ export function buildImageGenerationSubmissionPlan({
}
if (dialog.mode === 'audio-sound-effect') {
const soundType = dialog.soundType ?? 'ui';
const soundTempo = dialog.soundTempo ?? 'medium';
const soundType = dialog.soundType ?? 'one-shot';
const soundTempo = dialog.soundTempo ?? null;
return {
kind: 'audio',
audioKind: 'sound-effect',
@@ -408,9 +409,13 @@ export function buildImageGenerationSubmissionPlan({
export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
): IconSpritesheetGenerationSubmissionPlan {
const iconDescriptions = (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
const iconDescriptions = (dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,;/|]+/u)
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
)
.map((description) => description.trim())
.filter(Boolean);
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
if (!dialog.iconSpecReference) {
return {
@@ -436,6 +441,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.icon,
},
generationInputs: buildIconGenerationInputs(
iconDescriptions,
@@ -30,8 +30,7 @@ function IconComposerHarness({
initialMenuOpen = false,
onOpenSpecDialog = vi.fn(),
onRequestUpload = vi.fn(),
onUpdateIconDescription = vi.fn(),
onAddIconDescription = vi.fn(),
onUpdateIconDescriptionText = vi.fn(),
onRememberImageModel = vi.fn(),
onSubmit = vi.fn(),
}: {
@@ -39,8 +38,7 @@ function IconComposerHarness({
initialMenuOpen?: boolean;
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
onRequestUpload?: (target: UploadTarget) => void;
onUpdateIconDescription?: (index: number, value: string) => void;
onAddIconDescription?: () => void;
onUpdateIconDescriptionText?: (value: string) => void;
onRememberImageModel?: (model: string) => void;
onSubmit?: (dialog: GenerateDialogState) => void;
}) {
@@ -65,8 +63,7 @@ function IconComposerHarness({
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onUpdateIconDescription={onUpdateIconDescription}
onAddIconDescription={onAddIconDescription}
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmit}
/>
@@ -145,28 +142,24 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(screen.queryByRole('button', { name: '图标规范A' })).toBeNull();
});
it('updates descriptions, adds more descriptions and submits', () => {
const updateIconDescription = vi.fn();
const addIconDescription = vi.fn();
it('updates multi-line descriptions and submits', () => {
const updateIconDescriptionText = vi.fn();
const submitIconGeneration = vi.fn();
const dialog = createIconDialog();
const dialog = createIconDialog({ prompt: '剑\n盾' });
render(
<IconComposerHarness
initialDialog={dialog}
onUpdateIconDescription={updateIconDescription}
onAddIconDescription={addIconDescription}
onUpdateIconDescriptionText={updateIconDescriptionText}
onSubmit={submitIconGeneration}
/>,
);
fireEvent.change(screen.getByLabelText('素材描述1'), {
target: { value: '魔法剑' },
fireEvent.change(screen.getByLabelText('素材描述'), {
target: { value: '魔法剑\n圆盾' },
});
fireEvent.click(screen.getByRole('button', { name: '添加素材描述' }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(updateIconDescription).toHaveBeenCalledWith(0, '魔法剑');
expect(addIconDescription).toHaveBeenCalledTimes(1);
expect(updateIconDescriptionText).toHaveBeenCalledWith('魔法剑\n圆盾');
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
});
@@ -197,23 +190,19 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
it('disables generation controls and renders failure state', () => {
const submitIconGeneration = vi.fn();
const addIconDescription = vi.fn();
const { rerender } = render(
<IconComposerHarness
initialDialog={createIconDialog({ status: 'generating' })}
onAddIconDescription={addIconDescription}
onSubmit={submitIconGeneration}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(screen.getByRole('button', { name: '添加素材描述' }));
expect((screen.getByLabelText('素材描述1') as HTMLInputElement).disabled).toBe(
expect((screen.getByLabelText('素材描述') as HTMLInputElement).disabled).toBe(
true,
);
expect(submitIconGeneration).not.toHaveBeenCalled();
expect(addIconDescription).not.toHaveBeenCalled();
rerender(
<IconComposerHarness
@@ -7,7 +7,6 @@ import {
type SetStateAction,
} from 'react';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
@@ -20,12 +19,11 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
DEFAULT_ICON_DESCRIPTIONS,
EDITOR_GENERATION_MUD_POINT_CONFIG,
ICON_DESCRIPTION_LIMIT,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasIconSpritesheetComposerViewProps = {
@@ -43,8 +41,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
) => CSSProperties;
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
onUpdateIconDescription: (index: number, value: string) => void;
onAddIconDescription: () => void;
onUpdateIconDescriptionText: (value: string) => void;
onRememberImageModel: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -61,12 +58,13 @@ export function ImageCanvasIconSpritesheetComposerView({
buildPortalMenuStyle,
onOpenSpecDialog,
onRequestUpload,
onUpdateIconDescription,
onAddIconDescription,
onUpdateIconDescriptionText,
onRememberImageModel,
onSubmit,
}: ImageCanvasIconSpritesheetComposerViewProps) {
const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
const descriptionText =
dialog.prompt ||
(dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).join('\n');
useImageCanvasFloatingOptionDismiss({
isOpen: isIconSpecMenuOpen,
boundaryRefs: [iconSpecButtonRef],
@@ -98,7 +96,7 @@ export function ImageCanvasIconSpritesheetComposerView({
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
onSubmit({ ...dialog, prompt: descriptionText });
}
}}
>
@@ -138,70 +136,52 @@ export function ImageCanvasIconSpritesheetComposerView({
}
removeLabel="删除图标素材规范"
/>
{isIconSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标素材规范来源"
placement="top-start"
style={buildPortalMenuStyle(iconSpecButtonRef.current, 'above')}
{isIconSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标素材规范来源"
placement="top-start"
style={buildPortalMenuStyle(
iconSpecButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={pickIconSpecFromCanvas}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={pickIconSpecFromCanvas}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={openIconSpecDialog}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={requestIconSpecUpload}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__icon-description-list">
{descriptions.map((description, index) => (
<label
key={index}
className="image-canvas-editor__icon-description-card"
>
<span className="image-canvas-editor__icon-description-index">
{String(index + 1).padStart(2, '0')}
</span>
<span className="image-canvas-editor__icon-description-title">
{index + 1}
</span>
<PlatformTextField
aria-label={`素材描述${index + 1}`}
value={description}
disabled={dialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__icon-description-input"
onChange={(event) =>
onUpdateIconDescription(index, event.target.value)
}
/>
</label>
))}
</div>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={openIconSpecDialog}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={requestIconSpecUpload}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
aria-label="素材描述"
value={descriptionText}
disabled={dialog.status === 'generating'}
placeholder="描述要生成的多个素材,每行一个。"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
/>
</label>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -213,19 +193,6 @@ export function ImageCanvasIconSpritesheetComposerView({
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__icon-footer">
<button
type="button"
className="image-canvas-editor__character-reference-add"
disabled={
dialog.status === 'generating' ||
descriptions.length >= ICON_DESCRIPTION_LIMIT
}
onClick={onAddIconDescription}
>
</button>
</div>
<div className="image-canvas-editor__generation-composer-footer">
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
@@ -92,24 +92,21 @@ describe('ImageCanvasOverlayModel', () => {
).toBeNull();
});
it('expands icon composer width by description count', () => {
it('keeps icon composer aligned to the base generation composer width', () => {
expect(
resolveIconComposerStyle({
dialog: createDialog({ mode: 'icon' }),
composerStyle: { left: 100, top: 200 },
iconDescriptionCount: 6,
}),
).toEqual({
left: 100,
top: 200,
width: '52.8rem',
});
expect(
resolveIconComposerStyle({
dialog: createDialog({ mode: 'generate' }),
composerStyle: { left: 100, top: 200 },
iconDescriptionCount: 6,
}),
).toBeNull();
});
@@ -1,18 +1,14 @@
import {
TOOLBAR_HALF_WIDTH,
clamp,
TOOLBAR_HALF_WIDTH,
} from './ImageCanvasEditorModel';
import {
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
ICON_COMPOSER_MIN_WIDTH_REM,
ICON_DESCRIPTION_CARD_WIDTH_REM,
} from './ImageCanvasGenerationModel';
import type {
CanvasGenerationDialogMode,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
CropExpandPanelState,
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
@@ -72,23 +68,14 @@ export function resolveGenerationComposerStyle({
export function resolveIconComposerStyle({
dialog,
composerStyle,
iconDescriptionCount,
}: {
dialog: CanvasGenerationDialogState | null;
composerStyle: CanvasOverlayStyle | null;
iconDescriptionCount: number;
}): CanvasComposerOverlayStyle | null {
if (dialog?.mode !== 'icon' || !composerStyle) {
return null;
}
return {
...composerStyle,
width: `${Math.max(
ICON_COMPOSER_MIN_WIDTH_REM,
ICON_COMPOSER_HORIZONTAL_CHROME_REM +
iconDescriptionCount * ICON_DESCRIPTION_CARD_WIDTH_REM,
).toFixed(1)}rem`,
};
return composerStyle;
}
export function resolveSelectedToolbarStyle({
@@ -146,6 +133,37 @@ export function resolveQuickEditPanelStyle({
};
}
export function resolveCropExpandPanelStyle({
panel,
sourceLayer,
viewport,
canvasSize,
}: {
panel: CropExpandPanelState | null;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
canvasSize: CanvasSize;
}): CanvasOverlayStyle | null {
if (!panel || !sourceLayer) {
return null;
}
return {
left: clamp(
viewport.x +
(sourceLayer.x + sourceLayer.width / 2) * viewport.scale,
12,
Math.max(12, canvasSize.width - 12),
),
top: clamp(
viewport.y +
(sourceLayer.y + sourceLayer.height) * viewport.scale +
12,
12,
Math.max(12, canvasSize.height - 320),
),
};
}
export function resolveCharacterAnimationPanelStyle({
panel,
sourceLayer,
@@ -1,9 +1,9 @@
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { X } from 'lucide-react';
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasLayer,
@@ -39,12 +39,16 @@ export function ImageCanvasQuickEditPanelView({
setQuickEditPanel,
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
const isRedraw = panel.mode === 'redraw';
const isGenerating = panel.status === 'generating';
const panelLabel = isRedraw ? '重绘图片' : '快速编辑图片';
const promptLabel = isRedraw ? '重绘提示词' : '快速编辑提示词';
return (
<form
className="image-canvas-editor__quick-edit-panel"
style={style}
role="dialog"
aria-label="快速编辑图片"
aria-label={panelLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
@@ -57,7 +61,7 @@ export function ImageCanvasQuickEditPanelView({
<span>{sourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭快速编辑图片"
label={`关闭${panelLabel}`}
title="关闭"
icon={X}
onClick={() => setQuickEditPanel(null)}
@@ -65,8 +69,9 @@ export function ImageCanvasQuickEditPanelView({
</div>
<PlatformTextField
variant="textarea"
aria-label="快速编辑提示词"
aria-label={promptLabel}
value={panel.prompt}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__quick-edit-prompt"
@@ -83,8 +88,9 @@ export function ImageCanvasQuickEditPanelView({
/>
<div className="image-canvas-editor__quick-edit-controls">
<PlatformSelectField
aria-label="快速编辑尺寸"
aria-label={isRedraw ? '重绘尺寸' : '快速编辑尺寸'}
value={panel.size}
disabled={isGenerating}
size="xs"
density="compact"
onChange={(event) =>
@@ -102,8 +108,9 @@ export function ImageCanvasQuickEditPanelView({
))}
</PlatformSelectField>
<PlatformSelectField
aria-label="快速编辑模型"
aria-label={isRedraw ? '重绘模型' : '快速编辑模型'}
value={panel.model}
disabled={isGenerating}
size="xs"
density="compact"
onChange={(event) =>
@@ -131,8 +138,9 @@ export function ImageCanvasQuickEditPanelView({
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
disabled={isGenerating}
>
{isGenerating ? '生成中' : '生成'}
</PlatformActionButton>
</form>
);

Some files were not shown because too many files have changed in this diff Show More