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:
@@ -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
Reference in New Issue
Block a user