合并图标规范与图集生成优化
合入 PR 137 的图标规范生成、稳定引用与图集提示词链路 保留主分支现行音效提交、Prompt 模块与结果压缩逻辑 解决 External Worker、生成提交和客户端测试冲突
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user