合并图标规范与图集生成优化

合入 PR 137 的图标规范生成、稳定引用与图集提示词链路
保留主分支现行音效提交、Prompt 模块与结果压缩逻辑
解决 External Worker、生成提交和客户端测试冲突
This commit is contained in:
2026-08-08 21:44:15 +08:00
76 changed files with 7690 additions and 3063 deletions
@@ -23,6 +23,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());
@@ -57,6 +58,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
@@ -109,6 +111,7 @@ describe('ImageCanvasEditorView generation integration', () => {
beforeEach(() => {
window.localStorage.clear();
generateEditorIconSpecMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
@@ -2174,19 +2177,29 @@ 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, {
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: 'UI规范提示词',
actualPrompt: 'UI规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-ui-1',
}),
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,
sourceType: 'generated',
prompt: 'UI规范提示词',
actualPrompt: 'UI规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-ui-1',
},
),
);
render(<ImageCanvasEditorView />);
@@ -2198,18 +2211,13 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
'',
expect(screen.getByLabelText('玩法设定').textContent).toBe('');
expect(screen.getByLabelText('美术风格').textContent).toBe('');
setPlainTextEditorValue(
screen.getByLabelText('玩法设定'),
'消除类派对玩法',
);
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'',
);
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '消除类派对玩法' },
});
fireEvent.change(screen.getByLabelText('美术风格'), {
target: { value: '糖果玻璃拟物' },
});
setPlainTextEditorValue(screen.getByLabelText('美术风格'), '糖果玻璃拟物');
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
@@ -2218,23 +2226,26 @@ describe('ImageCanvasEditorView generation integration', () => {
);
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();
@@ -2705,7 +2716,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'resource-icon-spec',
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
@@ -3074,18 +3085,28 @@ 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, {
imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: '图标规范提示词',
actualPrompt: '图标规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-icon-spec-renamed-1',
}),
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,
sourceType: 'generated',
prompt: '图标规范提示词',
actualPrompt: '图标规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-icon-spec-renamed-1',
},
),
);
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==',
@@ -3108,29 +3129,32 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
fireEvent.change(within(specDialog).getByLabelText('玩法设定'), {
target: { value: '背包整理玩法' },
});
setPlainTextEditorValue(
within(specDialog).getByLabelText('玩法设定'),
'背包整理玩法',
);
setPlainTextEditorValue(
within(specDialog).getByLabelText('美术风格'),
'清爽卡通',
);
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: [],
},
}),
);
@@ -3163,7 +3187,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
referenceId: 'resource-editor-icon-spec-renamed-1',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
@@ -3179,6 +3203,90 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull();
});
it('uploads an authoritative icon spec before submitting icon spritesheet generation', async () => {
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
assetObjectId: 'asset-object-uploaded-icon-spec',
legacyPublicPath:
'/generated-character-drafts/editor/icon-specs/uploaded.png',
src: 'https://oss.example.com/icon-specs/uploaded.png',
});
createEditorProjectResourceMock.mockImplementationOnce(
async (projectId, input) => ({
resourceId: 'resource-uploaded-icon-spec',
projectId,
imageSrc: input.imageSrc,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
width: input.width,
height: input.height,
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
}),
);
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,dXBsb2FkZWQtc2hlZXQ=',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-uploaded-spec-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传媒体文件'),
new File(['uploaded-icon-spec'], '上传图标规范.png', {
type: 'image/png',
}),
);
await waitFor(() => {
expect(
within(iconPanel).getByRole('button', { name: '上传图标规范.png' }),
).toBeTruthy();
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
setPlainTextEditorValue(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
'上传后的返回按钮',
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceId: 'resource-uploaded-icon-spec',
iconDescriptions: ['上传后的返回按钮'],
projectId: 'editor-project-default',
}),
);
});
expect(
createEditorProjectResourceMock.mock.invocationCallOrder[0],
).toBeLessThan(
generateEditorIconSpritesheetMock.mock.invocationCallOrder[0]!,
);
});
it('exits character generation canvas picking with Escape', () => {
render(<ImageCanvasEditorView />);
@@ -1804,6 +1804,18 @@ describe('ImageCanvasEditorModel', () => {
).toBeUndefined();
});
it('migrates restored legacy ui specs to the icon spec flow', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-legacy-ui-spec',
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'ui',
})?.specType,
).toBe('icon');
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
@@ -1257,9 +1257,7 @@ export function hydrateCanvasGenerationDialog(
typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
? snapshot.specType
: undefined,
specType: normalizeSpecGenerationType(snapshot.specType),
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(
snapshot.specReference,
@@ -2380,12 +2378,16 @@ function isGenerationStatus(
function isSpecGenerationType(
value: unknown,
): value is NonNullable<CanvasGenerationDialogState['specType']> {
return (
value === 'character' ||
value === 'ui' ||
value === 'icon' ||
value === 'custom'
);
return value === 'character' || value === 'icon' || value === 'custom';
}
function normalizeSpecGenerationType(
value: unknown,
): CanvasGenerationDialogState['specType'] {
if (value === 'ui') {
return 'icon';
}
return isSpecGenerationType(value) ? value : undefined;
}
function hydratePublicationWorkflowId(
@@ -164,7 +164,7 @@ export type EditorAssetFolder = {
persisted: boolean;
};
export type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom';
export type SpecGenerationType = 'character' | 'icon' | 'custom';
export type SpecFormValues = {
playSetting: string;
@@ -183,7 +183,7 @@ function openCanvasStartupTool(
return;
}
if (tool === 'icon-spec') {
generationSurface.openSpecDialog('ui');
generationSurface.openSpecDialog('icon');
return;
}
if (tool === 'ui-design') {
@@ -1520,7 +1520,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"
@@ -89,25 +89,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,
@@ -247,7 +247,7 @@ export function createSceneGenerationDialogDraft({
}
function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) {
return specType === 'character' || specType === 'ui';
return specType === 'character' || specType === 'icon';
}
export function createSpecDialogDraft({
@@ -895,7 +895,7 @@ function resolveSpecTypeFromSourceLayer(
if (fields.has('头身比') || fields.has('角色视角')) {
return 'character';
}
return 'ui';
return 'icon';
}
function restoreSpecValuesFromLayer(
@@ -275,6 +275,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', () => {
@@ -413,17 +428,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('自定义');
@@ -456,21 +461,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',
@@ -568,17 +568,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 度纯右视图,也禁止生成正面立绘。',
@@ -596,7 +588,6 @@ export const DEFAULT_SPEC_FORM_VALUES: Record<
export const SPEC_TYPE_LABEL: Record<SpecGenerationType, string> = {
character: '角色规范',
ui: '图标规范',
icon: '图标规范',
custom: '自定义规范',
};
@@ -717,24 +708,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,
@@ -743,11 +716,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;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_GPT_IMAGE_2,
@@ -302,7 +303,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',
@@ -328,23 +329,47 @@ 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',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
playSetting: '休闲消除',
artStyle: '清爽卡通',
referenceId: 'resource-spec-ref',
generationInputs: {
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
result: {
assetKind: 'icon-spec',
title: '图标规范 8',
generationInputs: {
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
expect(plan.kind === 'icon-spec' ? plan.result.generationInputs : null).toEqual(
{
fields: [
{ title: '玩法设定', value: '休闲消除' },
@@ -362,13 +387,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: '糖果玻璃拟物',
@@ -382,17 +407,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',
);
});
@@ -751,6 +787,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the spec reference is not registered', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
iconSpecReference: {
id: 'local-icon-spec',
label: '本地图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'local-resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: '参考图尚未登记为项目资源或素材,请重新选择或上传后再试',
});
});
it('returns an icon spritesheet error when descriptions are empty', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -771,6 +826,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the complete prompt is too long', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
status: 'idle',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
});
});
it('builds icon spritesheet plans with the complete prompt and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -802,7 +876,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
ok: true,
iconDescriptions: ['返回按钮\n\n设置按钮'],
input: {
referenceImageSrc: 'data:image/png;base64,spec',
referenceId: 'resource-icon-spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
model: 'gpt-image-2',
@@ -6,6 +6,7 @@ import {
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpecGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageEditInput,
EditorImageGenerationInput,
@@ -13,6 +14,7 @@ import type {
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
@@ -101,6 +103,25 @@ export function resolveImageReferenceSubmissionSource(reference: {
return reference.objectKey?.trim() || reference.src;
}
export function resolveRegisteredEditorReferenceId(reference: {
resourceId?: string | null;
sourceAssetId?: string | null;
}) {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
!resourceId.startsWith('local-resource-') &&
!resourceId.startsWith('generation-dialog:')
) {
return resourceId;
}
const assetId = reference.sourceAssetId?.trim();
if (assetId) {
return assetId;
}
throw new Error('参考图尚未登记为项目资源或素材,请重新选择或上传后再试');
}
function buildSeedanceVideoReferenceInput(
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
@@ -198,6 +219,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;
@@ -419,6 +450,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
? {
referenceId: resolveRegisteredEditorReferenceId(
dialog.specReference,
),
}
: {}),
generationInputs,
},
result: {
assetKind: 'icon-spec',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
),
generationInputs,
},
};
}
return {
kind: 'image',
normalizedPrompt,
@@ -442,9 +504,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}`,
@@ -728,6 +788,24 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
errorMessage: '请填写素材描述',
};
}
if (Array.from(normalizedPrompt).length > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
return {
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
let referenceId: string;
try {
referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference);
} catch (error) {
return {
ok: false,
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '图标规范尚未完成资源登记,请重新选择或上传后再试',
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
@@ -736,9 +814,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
ok: true,
iconDescriptions,
input: {
referenceImageSrc: resolveImageReferenceSubmissionSource(
dialog.iconSpecReference,
),
referenceId,
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import {
getPlainTextEditorHost,
setPlainTextEditorValue,
@@ -212,6 +213,25 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
});
it('limits description input by Unicode characters', () => {
const updateIconDescriptionText = vi.fn();
render(
<IconComposerHarness
initialDialog={createIconDialog({ prompt: '' })}
onUpdateIconDescriptionText={updateIconDescriptionText}
/>,
);
setPlainTextEditorValue(
screen.getByLabelText('素材描述'),
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
);
expect(updateIconDescriptionText).toHaveBeenCalledWith(
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
);
});
it('keeps image option changes inside the icon dialog', () => {
const rememberImageModel = vi.fn();
render(
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
@@ -23,6 +24,10 @@ import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationMode
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
function limitIconDescriptionText(value: string) {
return Array.from(value).slice(0, EDITOR_ICON_DESCRIPTION_MAX_CHARS).join('');
}
type ImageCanvasIconSpritesheetComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
@@ -210,7 +215,9 @@ export function ImageCanvasIconSpritesheetComposerView({
disabled={dialog.status === 'generating'}
placeholder="你需要哪些图标?"
className="image-canvas-editor__generation-prompt"
onValueChange={onUpdateIconDescriptionText}
onValueChange={(value) =>
onUpdateIconDescriptionText(limitIconDescriptionText(value))
}
/>
</div>
{dialog.status === 'failed' ? (
@@ -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 {
getPlainTextEditorHost,
@@ -15,6 +22,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 {
@@ -83,6 +106,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() });
@@ -146,9 +220,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
'.auto-grow-text-area__placeholder',
)?.textContent,
).toBe('你希望这个 UI 长什么样?');
expect(getPlainTextEditorHost(prompt).className).toContain('auto-grow-text-area');
expect(getPlainTextEditorHost(prompt).className).toContain(
'auto-grow-text-area',
);
expect(prompt.className).not.toContain('platform-text-field');
expect(getPlainTextEditorHost(prompt).className).toContain('image-canvas-editor__generation-prompt');
expect(getPlainTextEditorHost(prompt).className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(prompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
@@ -239,7 +317,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
it('uses gameplay and art style hints for icon specs', () => {
renderPanel({
dialog: createSpecDialog({
specType: 'ui',
specType: 'icon',
specValues: {
playSetting: '',
artStyle: '',
@@ -250,18 +328,263 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
}),
});
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
'',
);
const playSetting = screen.getByLabelText('玩法设定');
const artStyle = screen.getByLabelText('美术风格');
expect(playSetting.textContent).toBe('');
expect(
(screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder,
).toBe('这是什么类型的游戏?');
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'',
);
getPlainTextEditorHost(playSetting).querySelector(
'.auto-grow-text-area__placeholder',
)?.textContent,
).toBe('游戏的核心玩法是什么?');
expect(artStyle.textContent).toBe('');
expect(
(screen.getByLabelText('美术风格') as HTMLInputElement).placeholder,
getPlainTextEditorHost(artStyle).querySelector(
'.auto-grow-text-area__placeholder',
)?.textContent,
).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('玩法设定');
const artStyle = screen.getByLabelText('美术风格');
expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe('5/200');
expect(screen.getByLabelText('美术风格字符计数').textContent).toBe('4/200');
expect(screen.getByLabelText('玩法设定字符计数').className).toContain(
'ml-auto',
);
expect(playSetting.hasAttribute('maxlength')).toBe(false);
expect(artStyle.hasAttribute('maxlength')).toBe(false);
setPlainTextEditorValue(playSetting, overLimit);
expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200);
setPlainTextEditorValue(playSetting, '🎮'.repeat(201));
expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200);
expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe(
'200/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('玩法设定').getAttribute('aria-disabled'),
).toBe('true');
expect(
screen.getByLabelText('美术风格').getAttribute('aria-disabled'),
).toBeNull();
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('玩法设定').textContent).toBe(
'优化后的玩法',
),
);
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(true);
await act(async () => artStyleDeferred.resolve('优化后的画风'));
await waitFor(() =>
expect(screen.getByLabelText('美术风格').textContent).toBe(
'优化后的画风',
),
);
expect(
(
screen.getByRole('button', {
name: '提交生成规范',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it('ignores an optimization result after the active dialog changes', async () => {
const deferred = createDeferred<string>();
const updateSpecFormValue = vi.fn();
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise);
const { rerender } = render(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-a', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
rerender(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-b', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
await act(async () => deferred.resolve('不应写入新对话框'));
expect(updateSpecFormValue).not.toHaveBeenCalled();
expect(screen.queryByRole('alert')).toBeNull();
});
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('玩法设定').textContent).toBe('第一次优化'),
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点');
expect(
screen.queryByRole('button', { name: '撤销玩法设定优化' }),
).toBeNull();
setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法');
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(screen.getByLabelText('玩法设定').textContent).toBe('第二次优化'),
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(screen.getByLabelText('玩法设定').textContent).toBe('手动修改玩法');
});
it('restores the exact pre-optimization value including surrounding whitespace', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法');
render(<IconSpecHarness playSetting=" 回合制占点 " />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(screen.getByLabelText('玩法设定').textContent).toBe(
'优化后的玩法',
),
);
expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith(
'回合制占点',
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(screen.getByLabelText('玩法设定').textContent).toBe(
' 回合制占点 ',
);
});
it('clears the field undo snapshot after a manual edit', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化');
render(<IconSpecHarness />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'),
);
expect(
screen.getByRole('button', { name: '撤销玩法设定优化' }),
).toBeTruthy();
setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法');
expect(
screen.queryByRole('button', { name: '撤销玩法设定优化' }),
).toBeNull();
});
it('leaves text and the existing valid 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('玩法设定').textContent).toBe('成功优化'),
);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'),
);
expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化');
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点');
});
it('renders custom prompt and hides reference upload for icon specs', () => {
@@ -282,7 +605,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
const customPrompt = screen.getByLabelText('自定义规范提示词');
expect(customPrompt.textContent).toBe('自定义提示');
expect(getPlainTextEditorHost(customPrompt).className).toContain('auto-grow-text-area');
expect(getPlainTextEditorHost(customPrompt).className).toContain(
'auto-grow-text-area',
);
expect(customPrompt.className).not.toContain('platform-text-field');
expect(getPlainTextEditorHost(customPrompt).className).toContain(
'image-canvas-editor__generation-prompt',
@@ -1,12 +1,27 @@
import { ClipboardList, Cpu, ImagePlus } from 'lucide-react';
import {
ClipboardList,
Cpu,
ImagePlus,
Loader2,
Sparkles,
Undo2,
} from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from 'react';
import {
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
} from '../../services/image-editor/editorProjectClient';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
@@ -63,7 +78,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({
@@ -103,6 +146,44 @@ 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,
);
const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`;
const activeOptimizationDialogKeyRef = useRef<string | null>(
optimizationDialogKey,
);
const optimizationRequestVersionsRef = useRef({
playSetting: 0,
artStyle: 0,
});
useEffect(() => {
activeOptimizationDialogKeyRef.current = optimizationDialogKey;
optimizationRequestVersionsRef.current.playSetting += 1;
optimizationRequestVersionsRef.current.artStyle += 1;
setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setOptimizationError(null);
return () => {
if (activeOptimizationDialogKeyRef.current === optimizationDialogKey) {
activeOptimizationDialogKeyRef.current = null;
}
};
}, [optimizationDialogKey]);
const isOptimizingIconSpec =
playSettingOptimization.optimizing || artStyleOptimization.optimizing;
const hasRequiredIconSpecValues =
isValidIconSpecPrompt(dialog.specValues?.playSetting) &&
isValidIconSpecPrompt(dialog.specValues?.artStyle);
const canSubmit =
!isGenerating &&
!hasPendingImageReferenceUploads &&
(!isIconSpec || (hasRequiredIconSpecValues && !isOptimizingIconSpec));
const specGenerationCost = calculateEditorSpecGenerationPrice(
SPEC_GENERATION_MODEL,
);
@@ -120,6 +201,73 @@ export function ImageCanvasSpecGenerationPanelView({
onRequestUpload(uploadTarget);
};
const optimizeIconSpecField = async (field: 'playSetting' | 'artStyle') => {
const isPlaySetting = field === 'playSetting';
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const undoValue = dialog.specValues?.[key] ?? '';
const value = undoValue.trim();
if (!value) {
return;
}
const setOptimization = isPlaySetting
? setPlaySettingOptimization
: setArtStyleOptimization;
const requestVersion = optimizationRequestVersionsRef.current[field] + 1;
optimizationRequestVersionsRef.current[field] = requestVersion;
const requestDialogKey = optimizationDialogKey;
setOptimization((current) => ({ ...current, optimizing: true }));
setOptimizationError(null);
try {
const refined = isPlaySetting
? await refineEditorIconSpecPlaySetting(value)
: await refineEditorIconSpecArtStyle(value);
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
setOptimization((current) => ({ ...current, optimizing: false }));
const fallbackMessage = isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败';
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
setOptimizationError(errorMessage);
}
};
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 (
<>
<div
@@ -276,7 +424,8 @@ export function ImageCanvasSpecGenerationPanelView({
}}
/>
</div>
) : dialog.specType === 'custom' ? (
) : null}
{!isUiDesignDialog && dialog.specType === 'custom' ? (
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
@@ -295,7 +444,118 @@ export function ImageCanvasSpecGenerationPanelView({
}
/>
</div>
) : (
) : null}
{!isUiDesignDialog && 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 (
<div
key={item.field}
className="image-canvas-editor__field-block"
>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
<AutoGrowTextArea
aria-label={item.title}
value={value}
placeholder={item.placeholder}
disabled={isGenerating || item.optimization.optimizing}
className="image-canvas-editor__generation-prompt image-canvas-editor__spec-textarea"
onValueChange={(nextValue) => {
onUpdateSpecFormValue(
item.key,
limitIconSpecPrompt(nextValue),
);
(item.field === 'playSetting'
? setPlaySettingOptimization
: setArtStyleOptimization)(
INITIAL_ICON_SPEC_OPTIMIZATION_STATE,
);
setOptimizationError(null);
}}
/>
<div className="flex items-center gap-1">
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
className="!gap-1 !px-2 !py-0.5 !text-[12px] !leading-3"
aria-label={`一键优化${item.title}`}
disabled={
isGenerating ||
item.optimization.optimizing ||
!isValidIconSpecPrompt(value)
}
onClick={() => optimizeIconSpecField(item.field)}
>
{item.optimization.optimizing ? (
<>
<Loader2
className="h-3 w-3 animate-spin"
aria-hidden="true"
/>
</>
) : (
<>
<Sparkles className="h-3 w-3" aria-hidden="true" />
</>
)}
</PlatformActionButton>
{item.optimization.undoValue !== null &&
!item.optimization.optimizing ? (
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
className="!gap-1 !px-2 !py-0.5 !text-[12px] !leading-3"
aria-label={`撤销${item.title}优化`}
disabled={isGenerating}
onClick={() => undoIconSpecOptimization(item.field)}
>
<Undo2 className="h-3 w-3" aria-hidden="true" />
</PlatformActionButton>
) : null}
<span
className="ml-auto text-[10px] leading-4 tabular-nums text-slate-400"
aria-label={`${item.title}字符计数`}
>
{getIconSpecPromptLength(value)}/
{EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
</span>
</div>
</div>
);
})}
</>
) : null}
{!isUiDesignDialog && dialog.specType !== 'custom' && !isIconSpec ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
@@ -403,7 +663,7 @@ export function ImageCanvasSpecGenerationPanelView({
</>
) : null}
</>
)}
) : null}
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
@@ -416,6 +676,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
@@ -474,7 +745,7 @@ export function ImageCanvasSpecGenerationPanelView({
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={isGenerating || hasPendingImageReferenceUploads}
disabled={!canSubmit}
aria-label="提交生成规范"
onClick={() => {
if (dialog.status !== 'generating') {
@@ -43,6 +43,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 generateEditorSceneMock = vi.hoisted(() => vi.fn());
@@ -67,6 +68,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
editEditorImage: editEditorImageMock,
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorScene: generateEditorSceneMock,
@@ -614,6 +616,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
extractEditorUiDesignAssetsMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpecMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
generateEditorSceneMock.mockReset();
@@ -2356,7 +2359,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-bgm',
@@ -2384,34 +2386,22 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(submitButton);
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledTimes(1);
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith({
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
projectId: 'editor-project-bgm',
generationInputs: {
fields: [
{
title: 'gpt_description_prompt',
value: canonicalPrompt,
},
],
references: [],
},
assetFolderId: 'project',
assetLabel: '游戏背景音乐 1',
canvasCompletion: {
dialogId: 'dialog-bgm',
title: '游戏背景音乐 1',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith(
expect.objectContaining({
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
generationInputs: {
fields: [
{
title: 'gpt_description_prompt',
value: canonicalPrompt,
},
],
references: [],
},
},
});
assetLabel: '游戏背景音乐 1',
}),
);
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
`audio-background-music:${canonicalPrompt}:idle:open`,
);
@@ -3584,6 +3574,157 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('does not add a local icon spec layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorIconSpecMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,icon-spec',
width: 2048,
height: 1152,
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-icon-spec"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-icon-spec-persisted',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'icon',
specValues: {
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
placeholder: {
x: 120,
y: 140,
width: 640,
height: 360,
originalWidth: 2048,
originalHeight: 1152,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-icon-spec',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-icon-spec-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'spec:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local video layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-video"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-video-persisted',
mode: 'video',
prompt: '让云朵飘动',
status: 'idle',
composerOpen: true,
placeholder: {
x: 160,
y: 120,
width: 640,
height: 360,
originalWidth: 1280,
originalHeight: 720,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-video',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-video-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'video:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local audio layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorSoundEffectMock.mockResolvedValueOnce(
createAudioGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-audio"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-audio-persisted',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-audio',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-audio-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('submits publication detail materials as one billable editor image and appends the result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
@@ -3718,7 +3859,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,
@@ -3759,21 +3900,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',
@@ -3840,6 +3986,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-spec',
},
iconDescriptions: ['旧返回', '旧设置'],
placeholder: {
@@ -3860,8 +4007,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
@@ -3927,6 +4073,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-spec',
},
iconDescriptions: [],
placeholder: {
@@ -3952,7 +4099,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
it('submits registered icon spec IDs and keeps objectKey extra references', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
@@ -3979,6 +4126,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
src: 'https://signed.example.test/icon-spec.png?expires=1',
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
assetObjectId: 'assetobj-icon-spec',
resourceId: 'resource-icon-spec',
},
generationReferences: [
{
@@ -4003,8 +4151,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
).not.toHaveBeenCalled();
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc:
'generated-character-drafts/editor/specs/icon-spec.png',
referenceId: 'resource-icon-spec',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/icon-style.png',
],
@@ -25,6 +25,7 @@ import {
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorScene,
@@ -83,7 +84,6 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveImageGenerationDialogPrompt,
} from './ImageCanvasGenerationSubmissionModel';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
import type {
@@ -362,7 +362,7 @@ type GenerationSubmissionWorkflowOptions = {
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages<
async function normalizeGenerationReferenceImages<
T extends { referenceImageSrcs?: string[] },
>(
input: T,
@@ -743,10 +743,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
const isBackgroundMusicSubmissionProjectCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
}) => {
(scope: { currentUserId: string | null; projectId: string | null }) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
return (
currentScope.mounted &&
@@ -1365,11 +1362,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
if (!canvasDialog) {
return;
}
const iconSpecReference = dialog.iconSpecReference;
if (!iconSpecReference) {
return;
}
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
@@ -1381,11 +1373,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceImageSrc = await resolveEditorGenerationMediaReference(
iconSpecReference,
'image',
projectId,
);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
@@ -1398,7 +1385,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
referenceImageSrc,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.generationInputs,
@@ -1884,6 +1870,26 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
]);
const resetProjectBackedGenerationDialog = useCallback(
(canvasDialog: CanvasGenerationDialogState | null) => {
if (!canvasDialog || !projectId) {
return false;
}
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return true;
},
[projectId, updateCanvasGenerationDialogById],
);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
@@ -2128,6 +2134,64 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canvasDialog?.id,
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput = submissionPlan.input;
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;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
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);
@@ -2174,6 +2238,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addVideoResultLayer(
generated,
submissionPlan.result.title,
@@ -2430,6 +2497,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addAudioResultLayer(
generated,
submissionPlan.result.title,
@@ -2438,12 +2508,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canvasDialog?.id,
);
} else if (submissionPlan.kind === 'scene') {
const sceneGenerationInput =
await normalizeImageGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const sceneGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
@@ -2487,26 +2556,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
if (generated.asset) upsertGeneratedAsset?.(generated.asset);
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) return;
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
@@ -2515,12 +2568,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: submissionPlan.result.generationInputs,
});
} else {
const imageGenerationInput =
await normalizeImageGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const imageGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const resultTitle =
submissionPlan.result.title ??
`生成图片 ${layerCounterRef.current + 1}`;
@@ -2572,19 +2624,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addGeneratedResultLayer(generated, {
@@ -2721,6 +2761,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
layers,
quickEditSelectionState,
rememberImageModel,
resetProjectBackedGenerationDialog,
projectId,
assetFolderId,
applyProjectSnapshot,
@@ -39,6 +39,7 @@ import {
} 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());
@@ -78,6 +79,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
loadEditorProject: loadEditorProjectMock,
@@ -609,8 +611,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设计生成
@@ -1120,6 +1131,7 @@ function GenerationWorkflowHarness({
id: 'spec-ref',
label: '参考.png',
src: 'data:image/png;base64,ref',
resourceId: 'resource-spec-ref',
},
}
: currentDialog,
@@ -2143,36 +2155,23 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('submits spec generation with reference image and reference prompt semantics', async () => {
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
generateEditorImageMock.mockResolvedValueOnce(
it('submits icon spec generation with business fields and a stable reference', async () => {
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',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
prompt: expect.stringContaining('参考图生成规范'),
playSetting: '消除玩法',
artStyle: '清爽卡通',
referenceId: 'resource-spec-ref',
}),
);
});
@@ -324,7 +324,7 @@ function UploadWorkflowHarness({
onClick={() =>
setGenerateDialog({
mode: 'spec',
specType: 'ui',
specType: 'icon',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
@@ -333,6 +333,18 @@ function UploadWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'icon',
prompt: '',
status: 'idle',
})
}
>
</button>
<button
type="button"
onClick={() =>
@@ -385,6 +397,12 @@ function UploadWorkflowHarness({
>
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('icon-spec')}
>
</button>
<button
type="button"
onClick={() => workflow.requestUpload('character-spec')}
@@ -766,6 +784,7 @@ describe('useImageCanvasUploadWorkflow', () => {
width: 420,
height: 315,
sourceType: 'uploaded',
assetKind: 'image',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
@@ -1195,6 +1214,57 @@ describe('useImageCanvasUploadWorkflow', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('persists uploaded icon specs as icon-spec project resources', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备图标素材生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('上传图标规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'icon:idle:-:0:上传图标规范.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('persists uploaded icon specs as icon-spec assets without a project', async () => {
render(<UploadWorkflowHarness projectId={null} />);
fireEvent.click(screen.getByRole('button', { name: '准备图标素材生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('素材库图标规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'icon:idle:-:0:素材库图标规范.png',
);
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('reserves the single spec reference slot across overlapping uploads', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const uploadGate = createDeferred<void>();
@@ -21,6 +21,7 @@ import {
resolveUploadAssetFolder,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasAssetKind,
CanvasHistoryAction,
CanvasLayer,
CanvasMediaType,
@@ -293,6 +294,7 @@ export function useImageCanvasUploadWorkflow({
objectKey,
assetObjectId,
reservation,
authoritativeAssetKind,
}: {
idPrefix: string;
index?: number;
@@ -306,6 +308,7 @@ export function useImageCanvasUploadWorkflow({
objectKey?: string | null;
assetObjectId?: string | null;
reservation?: ImageReferenceUploadReservation;
authoritativeAssetKind?: CanvasAssetKind;
}): Promise<CharacterReferenceImage> => {
const resolvedMediaType = mediaType ?? 'image';
let persistedImageSrc = imageSrc;
@@ -330,7 +333,8 @@ export function useImageCanvasUploadWorkflow({
) {
throw new ImageReferenceCapacityChangedError();
}
const assetKind = resolveUploadedAssetKind(resolvedMediaType);
const assetKind =
authoritativeAssetKind ?? resolveUploadedAssetKind(resolvedMediaType);
const generationInputs = buildUploadedMediaGenerationInputs(
resolvedMediaType,
durationSeconds,
@@ -419,6 +423,7 @@ export function useImageCanvasUploadWorkflow({
durationSeconds?: number;
objectKey?: string | null;
assetObjectId?: string | null;
authoritativeAssetKind?: CanvasAssetKind;
}>,
reservation?: ImageReferenceUploadReservation,
) => {
@@ -1174,6 +1179,7 @@ export function useImageCanvasUploadWorkflow({
file: imageFile,
fallbackLabel: '图标规范',
imageSrc,
authoritativeAssetKind: 'icon-spec',
},
],
reservation,
@@ -12,10 +12,15 @@ import {
deleteEditorProject,
editEditorImage,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorScene,
@@ -28,6 +33,8 @@ import {
loadEditorProject,
loadOrCreateRecentEditorProject,
optimizeEditorSoundEffectPrompt,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
@@ -1070,7 +1077,7 @@ describe('editorProjectClient', () => {
).rejects.toThrow('生成参考图最多允许 5 张');
await expect(
generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: references.slice(0, 5),
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
@@ -1105,7 +1112,7 @@ describe('editorProjectClient', () => {
);
await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/icon-spec.png',
referenceId: 'editor-resource-icon-spec',
referenceImageSrcs: references,
iconDescriptions: ['返回按钮'],
});
@@ -1288,7 +1295,7 @@ describe('editorProjectClient', () => {
});
const result = await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
assetLabel: '冒险游戏图标',
@@ -1305,7 +1312,7 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
@@ -1320,6 +1327,42 @@ describe('editorProjectClient', () => {
);
});
it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => {
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
}),
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: [
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
...Array.from({ length: 10 }, () =>
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
],
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: Array.from({ length: 8 }, () =>
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('passes image model options to icon spritesheet generation', async () => {
requestJsonMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
@@ -1334,7 +1377,7 @@ describe('editorProjectClient', () => {
});
await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
@@ -1356,7 +1399,7 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
@@ -1566,6 +1609,144 @@ 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('normalizes icon spec fields and rejects blank values before requesting', async () => {
await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow(
'玩法设定不能为空',
);
await expect(
generateEditorIconSpec({
playSetting: '回合制',
artStyle: ' ',
}),
).rejects.toThrow('美术风格不能为空');
expect(requestJsonMock).not.toHaveBeenCalled();
requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' });
await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe(
'优化玩法',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/llm/icon-specs/refine-game-play',
expect.objectContaining({
body: JSON.stringify({ playSetting: '原玩法' }),
}),
'优化玩法设定失败',
);
});
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: '低多边形',
referenceId: '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: '低多边形',
referenceId: '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');
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
timeoutMs: 1_200_000,
});
});
it('passes publication material generation kind and size to the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,publication',
+139 -24
View File
@@ -20,6 +20,16 @@ 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_SCENE_GENERATION_API = '/api/editor/scenes/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;
export const EDITOR_ICON_DESCRIPTION_LIMIT = 100;
export const EDITOR_ICON_DESCRIPTION_MAX_CHARS = 200;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS = 2_000;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES = 6 * 1024;
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
@@ -76,6 +86,19 @@ function assertStableEditorMediaReferences(
);
}
function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) {
const normalized = value.trim();
if (!normalized) {
throw new Error(`${fieldLabel}不能为空`);
}
if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
throw new Error(
`${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`,
);
}
return normalized;
}
function resolveEditorProviderReferenceLimit(model: string | null | undefined) {
const normalized = model?.trim();
return normalized === EDITOR_IMAGE_MODEL_NANOBANANA2 ||
@@ -96,6 +119,40 @@ function assertEditorReferenceLimit(
}
}
function normalizeEditorIconDescriptions(values: readonly string[]) {
const normalized = values.map((value) => value.trim()).filter(Boolean);
if (
normalized.length < 1 ||
normalized.length > EDITOR_ICON_DESCRIPTION_LIMIT
) {
throw new Error(
`图标素材描述数量必须在 1 到 ${EDITOR_ICON_DESCRIPTION_LIMIT} 个之间`,
);
}
normalized.forEach((description, index) => {
const actualLength = Array.from(description).length;
if (actualLength > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
throw new Error(
`${index + 1} 条图标素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
);
}
});
const prompt = normalized.join('\n');
const totalLength = Array.from(prompt).length;
if (totalLength > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
}
const totalUtf8Bytes = new TextEncoder().encode(prompt).byteLength;
if (totalUtf8Bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
}
return normalized;
}
function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) {
const references = [
...(input.referenceImageSrcs ?? []),
@@ -141,9 +198,7 @@ export type EditorAssetGenerationInputs = {
};
export type EditorProjectResourceSourceType =
| 'uploaded'
| 'generated'
| 'mock_generated';
'uploaded' | 'generated' | 'mock_generated';
export type EditorProjectResourceSnapshot = {
resourceId: string;
@@ -247,11 +302,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;
@@ -268,6 +319,18 @@ export type EditorImageGenerationInput = {
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
export type EditorIconSpecGenerationInput = {
playSetting: string;
artStyle: string;
referenceId?: 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;
@@ -282,7 +345,7 @@ export type EditorCanvasGenerationCompletionInput = {
};
export type EditorIconSpritesheetGenerationInput = {
referenceImageSrc: string;
referenceId: string;
referenceImageSrcs?: string[];
iconDescriptions: string[];
model?: string;
@@ -444,12 +507,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;
@@ -511,12 +569,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;
@@ -1121,11 +1174,73 @@ 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,
'美术风格',
);
return requestJson<EditorImageGenerationResponse>(
EDITOR_ICON_SPEC_GENERATION_API,
jsonRequest('POST', {
playSetting,
artStyle,
...(input.referenceId ? { referenceId: input.referenceId } : {}),
...(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,
},
);
}
export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
const iconDescriptions = normalizeEditorIconDescriptions(
input.iconDescriptions,
);
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范');
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
@@ -1138,11 +1253,11 @@ export async function generateEditorIconSpritesheet(
return requestJson<EditorIconSpritesheetGenerationResponse>(
EDITOR_ICON_SPRITESHEET_GENERATION_API,
jsonRequest('POST', {
referenceImageSrc: input.referenceImageSrc,
referenceId: input.referenceId,
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
iconDescriptions: input.iconDescriptions,
iconDescriptions,
model,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),