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