合并主分支

同步主线图标规范、生成参数恢复与统一画布生成链路

保留快速编辑的权威引用 ID、素材类型门禁与目标对象一致性校验

补齐 SpacetimeDB 引用解析、V2 引用槽位与相关测试门禁
This commit is contained in:
2026-08-08 23:56:25 +08:00
85 changed files with 12486 additions and 3653 deletions
@@ -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',
@@ -2047,7 +2228,9 @@ describe('editorProjectClient', () => {
);
expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
expect(requestJsonMock.mock.calls[0][3]).toEqual(
promptAssistRequestOptions,
);
});
it('simplifies a background music prompt without forwarding client-controlled limits', async () => {
@@ -2085,7 +2268,9 @@ describe('editorProjectClient', () => {
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
expect(requestJsonMock.mock.calls[0][3]).toEqual(
promptAssistRequestOptions,
);
});
it('optimizes an SFX prompt through the authenticated internal BFF without retries or client-controlled limits', async () => {
+162 -26
View File
@@ -20,6 +20,16 @@ const EDITOR_SHOWCASE_ASSET_API_BASE = '/api/editor/showcase/assets';
const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_SCENE_GENERATION_API = '/api/editor/scenes/generations';
const EDITOR_ICON_SPEC_GENERATION_API = '/api/editor/icon-specs/generations';
const EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API =
'/api/editor/llm/icon-specs/refine-game-play';
const EDITOR_ICON_SPEC_REFINE_ART_STYLE_API =
'/api/editor/llm/icon-specs/refine-art-style';
export const EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH = 200;
export const EDITOR_ICON_DESCRIPTION_LIMIT = 100;
export const EDITOR_ICON_DESCRIPTION_MAX_CHARS = 200;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS = 2_000;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES = 6 * 1024;
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
@@ -76,6 +86,19 @@ function assertStableEditorMediaReferences(
);
}
function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) {
const normalized = value.trim();
if (!normalized) {
throw new Error(`${fieldLabel}不能为空`);
}
if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
throw new Error(
`${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`,
);
}
return normalized;
}
function resolveEditorProviderReferenceLimit(model: string | null | undefined) {
const normalized = model?.trim();
return normalized === EDITOR_IMAGE_MODEL_NANOBANANA2 ||
@@ -96,6 +119,40 @@ function assertEditorReferenceLimit(
}
}
function normalizeEditorIconDescriptions(values: readonly string[]) {
const normalized = values.map((value) => value.trim()).filter(Boolean);
if (
normalized.length < 1 ||
normalized.length > EDITOR_ICON_DESCRIPTION_LIMIT
) {
throw new Error(
`图标素材描述数量必须在 1 到 ${EDITOR_ICON_DESCRIPTION_LIMIT} 个之间`,
);
}
normalized.forEach((description, index) => {
const actualLength = Array.from(description).length;
if (actualLength > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
throw new Error(
`第 ${index + 1} 条图标素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
);
}
});
const prompt = normalized.join('\n');
const totalLength = Array.from(prompt).length;
if (totalLength > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
}
const totalUtf8Bytes = new TextEncoder().encode(prompt).byteLength;
if (totalUtf8Bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
}
return normalized;
}
function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) {
const references = [
...(input.referenceImageSrcs ?? []),
@@ -122,18 +179,39 @@ export type EditorProjectLayerSnapshot = Record<string, unknown> & {
};
export type EditorAssetGenerationInputField = {
id?: string;
title: string;
value: string;
value: string | number | boolean;
};
export type EditorAssetGenerationInputReference = {
id?: string;
title: string;
label: string;
label?: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type EditorAssetGenerationInputs = {
version?: 2;
action?:
| 'image.generate'
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'icon.generate'
| 'ui-design.generate'
| 'publication.generate'
| 'video.generate'
| 'audio.sound-effect.generate'
| 'audio.background-music.generate'
| 'character-animation.generate'
| 'image.edit'
| 'ui-design.extract-assets'
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand';
fields: EditorAssetGenerationInputField[];
references: EditorAssetGenerationInputReference[];
soundEffect?: EditorSoundEffectGenerationMetadataV2;
@@ -141,9 +219,7 @@ export type EditorAssetGenerationInputs = {
};
export type EditorProjectResourceSourceType =
| 'uploaded'
| 'generated'
| 'mock_generated';
'uploaded' | 'generated' | 'mock_generated';
export type EditorProjectResourceSnapshot = {
resourceId: string;
@@ -247,11 +323,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 +340,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 +366,7 @@ export type EditorCanvasGenerationCompletionInput = {
};
export type EditorIconSpritesheetGenerationInput = {
referenceImageSrc: string;
referenceId: string;
referenceImageSrcs?: string[];
iconDescriptions: string[];
model?: string;
@@ -442,12 +526,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;
@@ -509,12 +588,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;
@@ -1119,11 +1193,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,
@@ -1136,11 +1272,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 } : {}),