重构:统一编辑器工作流中的 "icon-spec" 处理

- 将所有相关工作流从 "ui-spec" 迁移至 "icon-spec"。
- 优化 `playSetting` 和 `artStyle` 的输入处理逻辑。
- 更新编辑器提示词、提交模型及测试用例以适配 "icon-spec"。
- 在 UI 及后端调用中集成 `EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH` 及相关校验。
- 新增 "icon-spec" 生成与微调的专用 API 路径。
- 移除旧版 "ui-spec" 相关定义及提示词构建器。
This commit is contained in:
2026-08-04 20:04:07 +08:00
parent cc86d29b83
commit 3485bad2d3
19 changed files with 980 additions and 187 deletions
@@ -22,6 +22,7 @@ import {
} from './ImageCanvasEditorView.test-utils';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpecMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
@@ -55,6 +56,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
@@ -104,6 +106,7 @@ describe('ImageCanvasEditorView generation integration', () => {
beforeEach(() => {
window.localStorage.clear();
generateEditorIconSpecMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
@@ -2146,9 +2149,16 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
it('submits icon spec business fields through the dedicated endpoint', async () => {
generateEditorIconSpecMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse({
...input,
assetKind: 'icon-spec',
kind: 'spec',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
}, {
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
width: 2048,
height: 1152,
@@ -2185,23 +2195,26 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
model: 'gpt-image-2',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
projectId: 'editor-project-default',
assetFolderId: 'project',
assetKind: 'icon-spec',
generationInputs: {
fields: [
{ title: '玩法设定', value: '消除类派对玩法' },
{ title: '美术风格', value: '糖果玻璃拟物' },
],
references: [],
},
}),
);
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('玩法设定:消除类派对玩法');
expect(prompt).toContain('美术风格:糖果玻璃拟物');
const payload = generateEditorIconSpecMock.mock.calls[0]?.[0] ?? {};
expect(payload).not.toHaveProperty('prompt');
expect(payload).not.toHaveProperty('kind');
expect(payload).not.toHaveProperty('assetKind');
await waitFor(() => {
expect(screen.getByAltText(/画布图片:图标规范/)).toBeTruthy();
@@ -3043,8 +3056,15 @@ describe('ImageCanvasEditorView generation integration', () => {
});
it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => {
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
generateEditorIconSpecMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse({
...input,
assetKind: 'icon-spec',
kind: 'spec',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
}, {
imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
width: 2048,
height: 1152,
@@ -3080,26 +3100,27 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.change(within(specDialog).getByLabelText('玩法设定'), {
target: { value: '背包整理玩法' },
});
fireEvent.change(within(specDialog).getByLabelText('美术风格'), {
target: { value: '清爽卡通' },
});
fireEvent.click(
within(specDialog).getByRole('button', { name: '提交生成规范' }),
);
const generatedSpec = await screen.findByAltText(//u);
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
prompt: expect.stringContaining('背包整理玩法'),
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
projectId: 'editor-project-default',
assetFolderId: 'project',
assetKind: 'icon-spec',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '玩法设定',
value: '背包整理玩法',
}),
]),
}),
generationInputs: {
fields: [
{ title: '玩法设定', value: '背包整理玩法' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [],
},
}),
);
@@ -147,7 +147,8 @@ export type EditorAssetFolder = {
persisted: boolean;
};
export type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom';
// 这里指规范的类型
export type SpecGenerationType = 'character' | 'icon' | 'custom';
export type SpecFormValues = {
playSetting: string;
@@ -165,7 +165,7 @@ function openCanvasStartupTool(
return;
}
if (tool === 'icon-spec') {
generationSurface.openSpecDialog('ui');
generationSurface.openSpecDialog('icon');
return;
}
if (tool === 'ui-design') {
@@ -968,7 +968,7 @@ export function ImageCanvasGenerationComposerView({
onFocus={onSpecMenuPointerEnter}
onBlur={onSpecMenuPointerLeave}
>
{(['character', 'ui', 'custom'] as const).map((specType) => (
{(['character', 'icon', 'custom'] as const).map((specType) => (
<PlatformFloatingMenuItem
key={specType}
className="image-canvas-editor__spec-menu-item"
@@ -88,25 +88,14 @@ describe('ImageCanvasGenerationDialogModel', () => {
bodyRatio: '3',
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'ui' }),
).toMatchObject({
mode: 'spec',
specType: 'ui',
specValues: {
playSetting: '',
artStyle: '',
bodyRatio: '3',
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }),
).toMatchObject({
mode: 'spec',
specType: 'icon',
specValues: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
playSetting: '',
artStyle: '',
},
placeholder: {
x: -824,
@@ -199,7 +199,7 @@ export function createGenerateDialogDraft({
}
function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) {
return specType === 'character' || specType === 'ui';
return specType === 'character' || specType === 'icon';
}
export function createSpecDialogDraft({
@@ -811,7 +811,7 @@ function resolveSpecTypeFromSourceLayer(
if (fields.has('头身比') || fields.has('角色视角')) {
return 'character';
}
return 'ui';
return 'icon';
}
function restoreSpecValuesFromLayer(
@@ -257,6 +257,21 @@ describe('ImageCanvasGenerationModel', () => {
],
references: [],
});
expect(
buildSpecGenerationInputs('icon', {
playSetting: '休闲消除',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
}),
).toEqual({
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [],
});
});
it('builds character, icon and edit reference snapshots', () => {
@@ -395,17 +410,7 @@ describe('ImageCanvasGenerationModel', () => {
});
});
it('keeps generated prompts and quick edit options stable', () => {
const prompt = buildSpecPrompt('ui', {
playSetting: '消除玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
});
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
expect(prompt).toContain('玩法设定:消除玩法');
it('keeps custom prompts and quick edit options stable', () => {
expect(
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
).toBe('自定义');
@@ -438,21 +443,6 @@ describe('ImageCanvasGenerationModel', () => {
});
it('adds reference image semantics and snapshots for spec generation references', () => {
const prompt = buildSpecPrompt(
'ui',
{
playSetting: '消除玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
true,
);
expect(prompt).toContain('参考图生成规范');
expect(prompt).toContain('参考图1');
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
expect(
buildSpecPrompt(
'custom',
@@ -484,17 +484,9 @@ export const DEFAULT_SPEC_FORM_VALUES: Record<
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
ui: {
playSetting: '抓娃娃题材的抓大鹅玩法',
artStyle: '毛茸茸',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
icon: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
@@ -512,7 +504,6 @@ export const DEFAULT_SPEC_FORM_VALUES: Record<
export const SPEC_TYPE_LABEL: Record<SpecGenerationType, string> = {
character: '角色规范',
ui: '图标规范',
icon: '图标规范',
custom: '自定义规范',
};
@@ -609,24 +600,6 @@ export function buildCharacterSpecPrompt(values: SpecFormValues) {
].join('\n');
}
export function buildUiSpecPrompt(values: SpecFormValues) {
return [
'生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉',
'',
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`,
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`,
].join('\n');
}
export function buildIconSpecPrompt(values: SpecFormValues) {
return [
'生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。',
'',
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`,
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`,
].join('\n');
}
export function buildSpecPrompt(
type: SpecGenerationType,
values: SpecFormValues,
@@ -635,11 +608,7 @@ export function buildSpecPrompt(
const prompt =
type === 'character'
? buildCharacterSpecPrompt(values)
: type === 'ui'
? buildUiSpecPrompt(values)
: type === 'icon'
? buildIconSpecPrompt(values)
: values.customPrompt.trim();
: values.customPrompt.trim();
if (!hasReferenceImage) {
return prompt;
}
@@ -212,7 +212,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
);
});
it('builds spec generation plans with reference prompt semantics', () => {
it('builds icon spec generation plans without a client prompt or backend kind', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
@@ -238,23 +238,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
expect(plan).toMatchObject({
kind: 'image',
kind: 'icon-spec',
normalizedPrompt: 'AI 生成图片',
input: {
size: '2048x1152',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
kind: 'spec',
playSetting: '休闲消除',
artStyle: '清爽卡通',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
generationInputs: {
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
result: {
assetKind: 'icon-spec',
title: '图标规范 8',
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
expect(plan.kind === 'icon-spec' ? plan.result.generationInputs : null).toEqual(
{
fields: [
{ title: '玩法设定', value: '休闲消除' },
@@ -272,13 +282,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
);
});
it('treats the renamed UI spec entry as an icon spec asset', () => {
it('does not add optional reference fields to an icon spec without a reference', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'ui',
specType: 'icon',
specValues: {
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
@@ -292,17 +302,28 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
expect(plan).toMatchObject({
kind: 'image',
kind: 'icon-spec',
input: {
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
generationInputs: {
fields: [
{ title: '玩法设定', value: '消除类派对玩法' },
{ title: '美术风格', value: '糖果玻璃拟物' },
],
references: [],
},
},
result: {
assetKind: 'icon-spec',
title: '图标规范 2',
},
});
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty(
'referenceImageSrcs',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain(
'参考图生成规范',
expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty(
'prompt',
);
});
@@ -1,6 +1,7 @@
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpecGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageEditInput,
EditorImageGenerationInput,
@@ -162,6 +163,16 @@ export type ImageGenerationSubmissionPlan =
};
rememberImageModel?: string;
}
| {
kind: 'icon-spec';
normalizedPrompt: string;
input: EditorIconSpecGenerationInput;
result: {
assetKind: 'icon-spec';
title: string;
generationInputs: CanvasGenerationInputs;
};
}
| {
kind: 'quick-edit';
normalizedPrompt: string;
@@ -293,6 +304,37 @@ export function buildImageGenerationSubmissionPlan({
if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
if (specType === 'icon') {
const generationInputs = buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
);
return {
kind: 'icon-spec',
normalizedPrompt,
input: {
playSetting: specValues.playSetting.trim(),
artStyle: specValues.artStyle.trim(),
...(dialog.specReference?.src
? {
referenceImageSrcs: [
resolveImageReferenceSubmissionSource(dialog.specReference),
],
}
: {}),
generationInputs,
},
result: {
assetKind: 'icon-spec',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
),
generationInputs,
},
};
}
return {
kind: 'image',
normalizedPrompt,
@@ -316,9 +358,7 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
// 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。
assetKind:
specType === 'ui' || specType === 'icon' ? 'icon-spec' : 'spec',
assetKind: 'spec',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
@@ -36,7 +36,7 @@ function IconComposerHarness({
}: {
initialDialog: GenerateDialogState;
initialMenuOpen?: boolean;
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
onOpenSpecDialog?: (specType: 'character' | 'icon' | 'custom') => void;
onRequestUpload?: (target: UploadTarget) => void;
onUpdateIconDescriptionText?: (value: string) => void;
onRememberImageModel?: (model: string) => void;
@@ -1,8 +1,15 @@
/* @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
GenerateDialogState,
@@ -11,6 +18,22 @@ import type {
} from './ImageCanvasEditorTypes';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
const iconSpecClientMocks = vi.hoisted(() => ({
refineGamePlay: vi.fn(),
refineArtStyle: vi.fn(),
}));
vi.mock('../../services/image-editor/editorProjectClient', () => ({
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH: 200,
refineEditorIconSpecPlaySetting: iconSpecClientMocks.refineGamePlay,
refineEditorIconSpecArtStyle: iconSpecClientMocks.refineArtStyle,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function createSpecDialog(
patch: Partial<GenerateDialogState> = {},
): GenerateDialogState {
@@ -79,6 +102,57 @@ function UiDesignHarness({
) : null;
}
function IconSpecHarness({
playSetting = '回合制占点',
artStyle = '低多边形',
onSubmit = vi.fn(),
}: {
playSetting?: string;
artStyle?: string;
onSubmit?: (dialog: GenerateDialogState) => void;
}) {
const [dialog, setDialog] = useState<GenerateDialogState>(
createSpecDialog({
specType: 'icon',
specValues: {
playSetting,
artStyle,
bodyRatio: '3',
characterView: '右向斜侧身站姿',
customPrompt: '',
},
}),
);
return (
<ImageCanvasSpecGenerationPanelView
dialog={dialog}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={(key, value) =>
setDialog((current) => ({
...current,
specValues: {
...current.specValues!,
[key]: value,
},
}))
}
onRequestUpload={vi.fn()}
onSubmit={onSubmit}
/>
);
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe('ImageCanvasSpecGenerationPanelView', () => {
it('keeps the reference row above fields for spec panels and shows mud point text', () => {
renderPanel({ dialog: createSpecDialog() });
@@ -138,7 +212,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(true);
expect(panel.textContent).not.toContain('UI设计要求');
expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?');
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
expect(prompt.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(prompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
@@ -229,7 +305,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
it('uses gameplay and art style hints for icon specs', () => {
renderPanel({
dialog: createSpecDialog({
specType: 'ui',
specType: 'icon',
specValues: {
playSetting: '',
artStyle: '',
@@ -245,13 +321,198 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
);
expect(
(screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder,
).toBe('这是什么类型的游戏?');
).toBe('游戏的核心玩法是什么');
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'',
);
expect(
(screen.getByLabelText('美术风格') as HTMLInputElement).placeholder,
).toBe('游戏的画风是怎样的?');
expect(
(
screen.getByRole('button', {
name: '一键优化玩法设定',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
it('enforces the icon spec prompt limit in the fields and submit state', () => {
const overLimit = '玩'.repeat(201);
const onSubmit = vi.fn();
render(<IconSpecHarness onSubmit={onSubmit} />);
const playSetting = screen.getByLabelText(
'玩法设定',
) as HTMLTextAreaElement;
const artStyle = screen.getByLabelText('美术风格') as HTMLTextAreaElement;
expect(playSetting.maxLength).toBe(200);
expect(artStyle.maxLength).toBe(200);
fireEvent.change(playSetting, { target: { value: overLimit } });
expect(Array.from(playSetting.value)).toHaveLength(200);
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
cleanup();
renderPanel({
dialog: createSpecDialog({
specType: 'icon',
specValues: {
playSetting: overLimit,
artStyle: '低多边形',
bodyRatio: '3',
characterView: '右向斜侧身站姿',
customPrompt: '',
},
}),
onSubmit,
});
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '一键优化玩法设定',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
it('optimizes each icon spec field independently and disables generation until both finish', async () => {
const playSettingDeferred = createDeferred<string>();
const artStyleDeferred = createDeferred<string>();
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(
playSettingDeferred.promise,
);
iconSpecClientMocks.refineArtStyle.mockReturnValueOnce(
artStyleDeferred.promise,
);
render(<IconSpecHarness />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText('美术风格') as HTMLTextAreaElement).disabled,
).toBe(false);
expect(
screen.getByRole('button', { name: '一键优化玩法设定' }).textContent,
).toBe('正在优化');
fireEvent.click(screen.getByRole('button', { name: '一键优化美术风格' }));
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(true);
await act(async () => playSettingDeferred.resolve('优化后的玩法'));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('优化后的玩法'),
);
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(true);
await act(async () => artStyleDeferred.resolve('优化后的画风'));
await waitFor(() =>
expect(
(screen.getByLabelText('美术风格') as HTMLTextAreaElement).value,
).toBe('优化后的画风'),
);
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => {
iconSpecClientMocks.refineGamePlay
.mockResolvedValueOnce('第一次优化')
.mockResolvedValueOnce('第二次优化');
render(<IconSpecHarness />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('第一次优化'),
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('回合制占点');
expect(
screen.queryByRole('button', { name: '撤销玩法设定优化' }),
).toBeNull();
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '手动修改玩法' },
});
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('第二次优化'),
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('手动修改玩法');
});
it('leaves text and the existing undo snapshot unchanged when optimization fails', async () => {
iconSpecClientMocks.refineGamePlay
.mockResolvedValueOnce('成功优化')
.mockRejectedValueOnce(new Error('服务暂不可用'));
render(<IconSpecHarness />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('成功优化'),
);
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '失败前文本' },
});
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'),
);
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('失败前文本');
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('回合制占点');
});
it('renders custom prompt and hides reference upload for icon specs', () => {
@@ -5,8 +5,15 @@ import {
type ReactNode,
type RefObject,
type SetStateAction,
useEffect,
useState,
} from 'react';
import {
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
} from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
@@ -61,7 +68,35 @@ type ImageCanvasSpecGenerationPanelViewProps = {
};
function shouldShowSpecInputPlaceholders(dialog: GenerateDialogState) {
return dialog.specType === 'character' || dialog.specType === 'ui';
return dialog.specType === 'character' || dialog.specType === 'icon';
}
type IconSpecOptimizationState = {
optimizing: boolean;
undoValue: string | null;
};
const INITIAL_ICON_SPEC_OPTIMIZATION_STATE: IconSpecOptimizationState = {
optimizing: false,
undoValue: null,
};
function getIconSpecPromptLength(value: string) {
return Array.from(value).length;
}
function limitIconSpecPrompt(value: string) {
return Array.from(value)
.slice(0, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH)
.join('');
}
function isValidIconSpecPrompt(value: string | undefined) {
const normalized = value?.trim() ?? '';
return (
normalized.length > 0 &&
getIconSpecPromptLength(normalized) <= EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH
);
}
export function ImageCanvasSpecGenerationPanelView({
@@ -100,6 +135,27 @@ export function ImageCanvasSpecGenerationPanelView({
? dialog.uiDesignSpecReference
: dialog.specReference;
const isGenerating = dialog.status === 'generating';
const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon';
const [playSettingOptimization, setPlaySettingOptimization] =
useState<IconSpecOptimizationState>(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
const [artStyleOptimization, setArtStyleOptimization] =
useState<IconSpecOptimizationState>(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
const [optimizationError, setOptimizationError] = useState<string | null>(
null,
);
useEffect(() => {
setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setOptimizationError(null);
}, [dialog.id, dialog.mode, dialog.specType]);
const isOptimizingIconSpec =
playSettingOptimization.optimizing || artStyleOptimization.optimizing;
const hasRequiredIconSpecValues =
isValidIconSpecPrompt(dialog.specValues?.playSetting) &&
isValidIconSpecPrompt(dialog.specValues?.artStyle);
const canSubmit =
!isGenerating &&
(!isIconSpec || (hasRequiredIconSpecValues && !isOptimizingIconSpec));
const specGenerationCost = calculateEditorSpecGenerationPrice(
SPEC_GENERATION_MODEL,
);
@@ -117,6 +173,56 @@ export function ImageCanvasSpecGenerationPanelView({
onRequestUpload(uploadTarget);
};
const optimizeIconSpecField = async (field: 'playSetting' | 'artStyle') => {
const isPlaySetting = field === 'playSetting';
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const value = dialog.specValues?.[key]?.trim() ?? '';
if (!value) {
return;
}
const setOptimization = isPlaySetting
? setPlaySettingOptimization
: setArtStyleOptimization;
setOptimization((current) => ({ ...current, optimizing: true }));
setOptimizationError(null);
try {
const refined = isPlaySetting
? await refineEditorIconSpecPlaySetting(value)
: await refineEditorIconSpecArtStyle(value);
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
} catch (error) {
setOptimization((current) => ({ ...current, optimizing: false }));
setOptimizationError(
error instanceof Error && error.message.trim()
? error.message
: isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败',
);
}
};
const undoIconSpecOptimization = (field: 'playSetting' | 'artStyle') => {
const isPlaySetting = field === 'playSetting';
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const state = isPlaySetting
? playSettingOptimization
: artStyleOptimization;
if (state.undoValue === null) {
return;
}
onUpdateSpecFormValue(key, state.undoValue);
(isPlaySetting ? setPlaySettingOptimization : setArtStyleOptimization)(
INITIAL_ICON_SPEC_OPTIMIZATION_STATE,
);
setOptimizationError(null);
};
return (
<>
<form
@@ -127,7 +233,7 @@ export function ImageCanvasSpecGenerationPanelView({
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
if (canSubmit) {
onSubmit(dialog);
}
}}
@@ -305,6 +411,99 @@ export function ImageCanvasSpecGenerationPanelView({
}
/>
</label>
) : isIconSpec ? (
<>
{(
[
{
field: 'playSetting' as const,
key: 'playSetting' as const,
title: '玩法设定',
placeholder: '游戏的核心玩法是什么?',
optimization: playSettingOptimization,
},
{
field: 'artStyle' as const,
key: 'artStyle' as const,
title: '美术风格',
placeholder: '游戏的画风是怎样的?',
optimization: artStyleOptimization,
},
] as const
).map((item) => {
const value = dialog.specValues?.[item.key] ?? '';
return (
<label
key={item.field}
className="image-canvas-editor__field-block"
>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
<div>
<PlatformTextField
variant="textarea"
aria-label={item.title}
value={value}
maxLength={EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
placeholder={item.placeholder}
disabled={isGenerating || item.optimization.optimizing}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
onChange={(event) =>
onUpdateSpecFormValue(
item.key,
limitIconSpecPrompt(event.target.value),
)
}
/>
<div>
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
aria-label={`一键优化${item.title}`}
disabled={
isGenerating ||
item.optimization.optimizing ||
!isValidIconSpecPrompt(value)
}
onClick={() => optimizeIconSpecField(item.field)}
>
{item.optimization.optimizing
? '正在优化'
: '✦ 一键优化'}
</PlatformActionButton>
{item.optimization.undoValue !== null &&
!item.optimization.optimizing ? (
<>
<span aria-hidden="true"></span>
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
aria-label={`撤销${item.title}优化`}
disabled={isGenerating}
onClick={() =>
undoIconSpecOptimization(item.field)
}
>
</PlatformActionButton>
</>
) : null}
</div>
</div>
</label>
);
})}
</>
) : (
<>
<label className="image-canvas-editor__field-block">
@@ -426,6 +625,17 @@ export function ImageCanvasSpecGenerationPanelView({
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
{optimizationError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{optimizationError}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__spec-footer">
{isUiDesignDialog ? (
<ImageCanvasGenerationImageOptionsView
@@ -478,7 +688,7 @@ export function ImageCanvasSpecGenerationPanelView({
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={isGenerating}
disabled={!canSubmit}
aria-label="提交生成规范"
>
{isGenerating ? (
@@ -39,6 +39,7 @@ const editEditorImageMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpecMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorVideoMock = vi.hoisted(() => vi.fn());
@@ -62,6 +63,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
editEditorImage: editEditorImageMock,
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorVideo: generateEditorVideoMock,
@@ -505,6 +507,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
extractEditorUiDesignAssetsMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpecMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
generateEditorVideoMock.mockReset();
@@ -2238,7 +2241,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
it('submits icon spec objects without requiring an icon spec reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
generateEditorIconSpecMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,icon-spec',
width: 2048,
@@ -2279,21 +2282,26 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
assetKind: 'icon-spec',
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
assetLabel: '图标规范 1',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
generationInputs: {
fields: [
{ title: '玩法设定', value: '背包整理玩法' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [],
},
}),
);
});
const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {};
const payload = generateEditorIconSpecMock.mock.calls[0]?.[0] ?? {};
expect(payload).not.toHaveProperty('referenceImageSrcs');
expect(payload.prompt).toContain('背包整理玩法');
expect(payload.prompt).not.toContain('参考图生成规范');
expect(payload).not.toHaveProperty('prompt');
expect(payload).not.toHaveProperty('kind');
expect(payload).not.toHaveProperty('assetKind');
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:图标规范 1:-:icon-spec',
@@ -23,6 +23,7 @@ import {
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorSoundEffect,
@@ -334,6 +335,30 @@ async function normalizeImageGenerationReferenceImages(
};
}
async function normalizeIconSpecGenerationReferenceImages(
input: Parameters<typeof generateEditorIconSpec>[0],
references: CharacterReferenceImage[],
projectId?: string | null,
) {
if (!input.referenceImageSrcs?.length) {
return input;
}
return {
...input,
referenceImageSrcs: await Promise.all(
input.referenceImageSrcs.map((referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
references[index]
? { ...references[index], src: referenceImageSrc }
: { src: referenceImageSrc },
'image',
projectId,
),
),
),
};
}
function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
@@ -1915,6 +1940,66 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canvasDialog?.id,
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput =
await normalizeIconSpecGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpec({
...iconSpecInput,
projectId,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
canvasDialog?.id,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs:
generated.asset?.generationInputs ??
submissionPlan.result.generationInputs,
});
} else if (submissionPlan.kind === 'video') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
@@ -23,6 +23,7 @@ import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpecMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
@@ -59,6 +60,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
@@ -318,8 +320,17 @@ function GenerationWorkflowHarness({
<button type="button" onClick={workflow.openIconGenerationDialog}>
</button>
<button type="button" onClick={() => workflow.openSpecDialog('ui')}>
UI规范
<button type="button" onClick={() => workflow.openSpecDialog('icon')}>
</button>
<button
type="button"
onClick={() => {
workflow.updateSpecFormValue('playSetting', '消除玩法');
workflow.updateSpecFormValue('artStyle', '清爽卡通');
}}
>
</button>
<button type="button" onClick={workflow.openUiDesignGenerationDialog}>
UI设计生成
@@ -1642,7 +1653,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('submits spec generation with reference image and reference prompt semantics', async () => {
it('submits icon spec generation with business fields and a stable reference', async () => {
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
@@ -1651,27 +1662,24 @@ describe('useImageCanvasGenerationWorkflow', () => {
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
generateEditorImageMock.mockResolvedValueOnce(
generateEditorIconSpecMock.mockResolvedValueOnce(
createGenerated({ prompt: 'UI规范图' }),
);
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开UI规范' }));
fireEvent.click(screen.getByRole('button', { name: '打开图标规范' }));
fireEvent.click(screen.getByRole('button', { name: '填写图标规范' }));
fireEvent.click(screen.getByRole('button', { name: '添加规范参考图' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
playSetting: '消除玩法',
artStyle: '清爽卡通',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
prompt: expect.stringContaining('参考图生成规范'),
}),
);
});
@@ -309,7 +309,7 @@ function UploadWorkflowHarness({
onClick={() =>
setGenerateDialog({
mode: 'spec',
specType: 'ui',
specType: 'icon',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
@@ -9,9 +9,11 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorSoundEffect,
@@ -23,6 +25,8 @@ import {
loadEditorProject,
loadOrCreateRecentEditorProject,
removeEditorImageBackground,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
renameEditorProject,
saveEditorProjectLayout,
splitEditorIconSpritesheet,
@@ -282,7 +286,9 @@ describe('editorProjectClient', () => {
nextCursor: null,
});
const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' });
const page = await listPublicEditorProjectResources({
cursor: ' cursor-1 ',
});
expect(page.resources).toEqual([]);
expect(page.nextCursor).toBeNull();
@@ -749,8 +755,7 @@ describe('editorProjectClient', () => {
taskId: 'vector-task-1',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
reason: '生成任务成功,后处理失败。',
},
});
@@ -761,8 +766,7 @@ describe('editorProjectClient', () => {
expect(result.taskId).toBe('vector-task-1');
expect(result.warning).toEqual({
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
reason: '生成任务成功,后处理失败。',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/generations',
@@ -812,7 +816,9 @@ describe('editorProjectClient', () => {
resolution: '720p',
mode: 'std',
sound: 'off',
referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`],
referenceVideoSrcs: [
`https://assets.example.test/${'a'.repeat(256 * 1024)}`,
],
}),
).rejects.toThrow('稳定引用字段总长度不能超过 256KB');
@@ -1242,6 +1248,116 @@ describe('editorProjectClient', () => {
);
});
it('uses dedicated icon spec refine endpoints', async () => {
requestJsonMock
.mockResolvedValueOnce({ playSetting: '优化玩法' })
.mockResolvedValueOnce({ artStyle: '优化画风' });
await expect(refineEditorIconSpecPlaySetting('原玩法')).resolves.toBe(
'优化玩法',
);
await expect(refineEditorIconSpecArtStyle('原画风')).resolves.toBe(
'优化画风',
);
expect(requestJsonMock.mock.calls[0]).toEqual([
'/api/editor/llm/icon-specs/refine-game-play',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ playSetting: '原玩法' }),
}),
'优化玩法设定失败',
]);
expect(requestJsonMock.mock.calls[1]).toEqual([
'/api/editor/llm/icon-specs/refine-art-style',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ artStyle: '原画风' }),
}),
'优化美术风格失败',
]);
});
it('enforces icon spec prompt length before and after refine requests', async () => {
const overLimit = '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1);
await expect(refineEditorIconSpecPlaySetting(overLimit)).rejects.toThrow(
'玩法设定不能超过 200 个字符',
);
await expect(refineEditorIconSpecArtStyle(overLimit)).rejects.toThrow(
'美术风格不能超过 200 个字符',
);
expect(requestJsonMock).not.toHaveBeenCalled();
requestJsonMock.mockResolvedValueOnce({ playSetting: overLimit });
await expect(refineEditorIconSpecPlaySetting('原玩法')).rejects.toThrow(
'玩法设定不能超过 200 个字符',
);
});
it('rejects overlong icon spec generation fields before dispatch', async () => {
await expect(
generateEditorIconSpec({
playSetting: '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1),
artStyle: '低多边形',
}),
).rejects.toThrow('玩法设定不能超过 200 个字符');
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('submits icon spec business fields without a client prompt or image kind', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,spec',
width: 2048,
height: 1152,
});
await generateEditorIconSpec({
playSetting: '回合制占点',
artStyle: '低多边形',
referenceImageSrcs: ['resource-reference-1'],
projectId: 'editor-project-1',
generationInputs: {
fields: [],
references: [
{
title: '参考图',
label: '参考图',
refType: 'project-resource',
refId: 'resource-reference-1',
},
],
},
assetFolderId: 'project',
assetLabel: '图标规范 1',
sourceResourceId: 'resource-source-1',
});
const body = JSON.parse(requestJsonMock.mock.calls[0]?.[1]?.body as string);
expect(body).toEqual({
playSetting: '回合制占点',
artStyle: '低多边形',
referenceImageSrcs: ['resource-reference-1'],
projectId: 'editor-project-1',
generationInputs: {
fields: [],
references: [
{
title: '参考图',
label: '参考图',
refType: 'project-resource',
refId: 'resource-reference-1',
},
],
},
assetFolderId: 'project',
assetLabel: '图标规范 1',
sourceResourceId: 'resource-source-1',
});
expect(body).not.toHaveProperty('prompt');
expect(body).not.toHaveProperty('kind');
expect(body).not.toHaveProperty('assetKind');
});
it('passes publication material generation kind and size to the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,publication',
@@ -8,6 +8,12 @@ const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources';
const EDITOR_SHOWCASE_ASSET_API_BASE = '/api/editor/showcase/assets';
const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_ICON_SPEC_GENERATION_API = '/api/editor/icon-specs/generations';
const EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API =
'/api/editor/llm/icon-specs/refine-game-play';
const EDITOR_ICON_SPEC_REFINE_ART_STYLE_API =
'/api/editor/llm/icon-specs/refine-art-style';
export const EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH = 200;
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
@@ -41,7 +47,18 @@ function assertStableEditorMediaReferences(
values: readonly string[] | undefined,
fieldLabel: string,
) {
values?.forEach((value) => assertStableEditorMediaReference(value, fieldLabel));
values?.forEach((value) =>
assertStableEditorMediaReference(value, fieldLabel),
);
}
function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) {
if (Array.from(value).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
throw new Error(
`${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`,
);
}
return value;
}
function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) {
@@ -86,9 +103,7 @@ export type EditorAssetGenerationInputs = {
};
export type EditorProjectResourceSourceType =
| 'uploaded'
| 'generated'
| 'mock_generated';
'uploaded' | 'generated' | 'mock_generated';
export type EditorProjectResourceSnapshot = {
resourceId: string;
@@ -190,11 +205,7 @@ export type EditorImageGenerationInput = {
prompt: string;
size?: string;
kind?:
| 'spec'
| 'character'
| 'quick-edit'
| 'ui-design'
| 'publication-material';
'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material';
model?: string;
screenColor?: string;
segModel?: string;
@@ -211,6 +222,18 @@ export type EditorImageGenerationInput = {
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
export type EditorIconSpecGenerationInput = {
playSetting: string;
artStyle: string;
referenceImageSrcs?: string[];
projectId?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
sourceResourceId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
export type EditorCanvasGenerationCompletionInput = {
dialogId?: string;
title: string;
@@ -358,12 +381,7 @@ export type EditorIconSpritesheetSliceResult = {
export type EditorCharacterAnimationResolution = '480p' | '720p';
export type EditorCharacterAnimationRatio =
| 'same'
| '1:1'
| '4:3'
| '16:9'
| '9:16'
| '3:4';
'same' | '1:1' | '4:3' | '16:9' | '9:16' | '3:4';
export type EditorCharacterAnimationFrameCount = 32 | 40 | 48;
@@ -424,12 +442,7 @@ export type EditorVideoModel =
export type EditorVideoResolution = '480p' | '720p' | '1080p';
export type EditorVideoSoundMode = 'on' | 'off';
export type EditorVideoAspectRatio =
| '16:9'
| '9:16'
| '1:1'
| '4:3'
| '3:4'
| '21:9';
'16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';
export type EditorVideoGenerationInput = {
prompt: string;
@@ -948,14 +961,75 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
);
}
export async function refineEditorIconSpecPlaySetting(playSetting: string) {
const validPlaySetting = requireEditorIconSpecPromptLength(
playSetting,
'玩法设定',
);
const response = await requestJson<{ playSetting: string }>(
EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API,
jsonRequest('POST', { playSetting: validPlaySetting }),
'优化玩法设定失败',
);
return requireEditorIconSpecPromptLength(response.playSetting, '玩法设定');
}
export async function refineEditorIconSpecArtStyle(artStyle: string) {
const validArtStyle = requireEditorIconSpecPromptLength(artStyle, '美术风格');
const response = await requestJson<{ artStyle: string }>(
EDITOR_ICON_SPEC_REFINE_ART_STYLE_API,
jsonRequest('POST', { artStyle: validArtStyle }),
'优化美术风格失败',
);
return requireEditorIconSpecPromptLength(response.artStyle, '美术风格');
}
export async function generateEditorIconSpec(
input: EditorIconSpecGenerationInput,
) {
const playSetting = requireEditorIconSpecPromptLength(
input.playSetting,
'玩法设定',
);
const artStyle = requireEditorIconSpecPromptLength(
input.artStyle,
'美术风格',
);
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标规范参考图');
return requestJson<EditorImageGenerationResponse>(
EDITOR_ICON_SPEC_GENERATION_API,
jsonRequest('POST', {
playSetting,
artStyle,
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.canvasCompletion
? { canvasCompletion: input.canvasCompletion }
: {}),
}),
'生成图标规范失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范');
assertStableEditorMediaReferences(
input.referenceImageSrcs,
'图标素材参考图',
);
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
return requestJson<EditorIconSpritesheetGenerationResponse>(
EDITOR_ICON_SPRITESHEET_GENERATION_API,
jsonRequest('POST', {