Files
Genarrative/src/components/image-editor/ImageCanvasGenerationModel.test.ts
T
k88936 3485bad2d3 重构:统一编辑器工作流中的 "icon-spec" 处理
- 将所有相关工作流从 "ui-spec" 迁移至 "icon-spec"。
- 优化 `playSetting` 和 `artStyle` 的输入处理逻辑。
- 更新编辑器提示词、提交模型及测试用例以适配 "icon-spec"。
- 在 UI 及后端调用中集成 `EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH` 及相关校验。
- 新增 "icon-spec" 生成与微调的专用 API 路径。
- 移除旧版 "ui-spec" 相关定义及提示词构建器。
2026-08-04 20:04:07 +08:00

585 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
applyEditorGenerationPricingConfig,
BACKGROUND_MUSIC_MODEL_SUNO,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditGenerationInputs,
buildQuickEditModelOptions,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignAssetExtractionGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorImageGenerationPrice,
calculateEditorImageModelPrice,
calculateEditorSoundEffectPrice,
calculateEditorSpecGenerationPrice,
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_OPTIONS,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
isQuickEditUnsupportedAssetKind,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
VIDEO_MODEL_KLING_3_OMNI,
VIDEO_MODEL_SEEDANCE_2,
VIDEO_MODEL_VEO_3_1,
VIDEO_MODEL_VEO_3_1_FAST,
} from './ImageCanvasGenerationModel';
describe('ImageCanvasGenerationModel', () => {
afterEach(() => {
applyEditorGenerationPricingConfig({
models: EDITOR_MODEL_MUD_POINT_CONFIG,
});
});
it('为所有编辑器生成模型提供显式定价配置', () => {
expect(
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K'],
).toBe(3);
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
).toBe(5);
expect(
EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
).toBeTruthy();
expect(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
).toBe(5);
expect(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
).toBe(12);
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
expect(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
).toBeTruthy();
expect(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
).toBeTruthy();
});
it.each([
[IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152],
] as const)(
'%s 的 %s %s 占位尺寸与统一业务交付尺寸一致',
(model, imageSize, aspectRatio, width, height) => {
expect(
resolveEditorImageGenerationPixelSize({
model,
aspectRatio,
imageSize,
}),
).toEqual({ width, height });
},
);
it('所有生成按钮价格都从模型定价配置推导', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
3,
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
5,
);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
model: IMAGE_MODEL_GPT_IMAGE_2,
imageSize: '1K',
}),
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(150);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
200,
);
expect(
calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6),
).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] *
6,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
);
expect(
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
).toBe(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
);
});
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
applyEditorGenerationPricingConfig({
models: {
...EDITOR_MODEL_MUD_POINT_CONFIG,
[IMAGE_MODEL_GPT_IMAGE_2]: {
unit: 'perGeneration',
prices: {
'1K': 29,
'2K': 58,
},
},
[VIDEO_MODEL_SEEDANCE_2]: {
unit: 'perSecond',
prices: {
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
'720p': 26,
},
},
[DEFAULT_SOUND_EFFECT_MODEL]: {
unit: 'perGeneration',
price: 15,
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price: 9,
},
},
});
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
29,
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
58,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
130,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
15,
);
expect(
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
).toBe(9);
});
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
references: [],
});
expect(
buildSpecGenerationInputs('character', {
playSetting: '平台跳跃',
artStyle: '像素风',
bodyRatio: '3',
characterView: '右向三分之二侧身',
customPrompt: '',
}),
).toEqual({
fields: [
{ title: '玩法设定', value: '平台跳跃' },
{ title: '美术风格', value: '像素风' },
{ title: '头身比', value: '3' },
{ title: '角色视角', value: '右向三分之二侧身' },
],
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', () => {
const sourceLayer = buildSourceLayer();
expect(
buildCharacterGenerationInputs(
'主角骑士',
{
id: 'spec',
label: '角色规范',
src: '/spec.png',
resourceId: 'resource-spec',
},
[
{
id: 'ref-1',
label: '盔甲参考',
src: '/armor.png',
resourceId: 'resource-armor',
},
],
),
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
references: [
{
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
});
expect(
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
id: 'icon-spec',
label: '图标规范',
src: '/icon-spec.png',
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
});
expect(
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
).toEqual({
fields: [{ title: '修改要求', value: '换成夜晚' }],
references: [
{
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
});
expect(
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
{
id: 'ref-ui',
label: 'UI风格参考',
src: '/ui-style.png',
resourceId: 'resource-ui-style',
},
]),
).toEqual({
fields: [
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
],
references: [
{
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
});
expect(
buildQuickEditGenerationInputs(
'快速编辑提示词',
'让图2下雨',
sourceLayer,
[
{
id: 'ref-1',
label: '天空',
src: '/sky.png',
resourceId: 'resource-sky',
},
],
),
).toEqual({
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
references: [
{
title: '原图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
],
});
});
it('keeps custom prompts and quick edit options stable', () => {
expect(
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
).toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditUnsupportedAssetKind({
...buildSourceLayer(),
assetKind: 'icon',
}),
).toBe(true);
expect(
isQuickEditUnsupportedAssetKind({
...buildSourceLayer(),
assetKind: 'icon-spritesheet',
}),
).toBe(false);
});
it('adds reference image semantics and snapshots for spec generation references', () => {
expect(
buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
),
).toContain('生成一张怪兽规范图');
expect(
buildSpecGenerationInputs(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
{
id: 'spec-ref',
label: '参考.png',
src: '/ref.png',
resourceId: 'resource-ref',
},
),
).toEqual({
fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-ref',
},
],
});
});
it('uses objectKey for character animation references before falling back to src', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
objectKey: 'generated/character.png',
}),
).toBe('generated/character.png');
expect(resolveCharacterAnimationSourceImageSrc(buildSourceLayer())).toBe(
'/source.png',
);
});
it('uses the extracted transparent frame when reusing a character animation sequence layer', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
assetKind: 'character-animation',
mediaType: 'image-sequence',
src: '/generated-character-drafts/editor/frame01.png',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
}),
).toBe('/generated-character-drafts/editor/frame01.png');
});
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
const iconDialog: CanvasGenerationDialogState = {
id: 'dialog-icon',
mode: 'icon',
prompt: '',
status: 'idle',
};
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
getGenerationFrameAriaLabel({
...iconDialog,
mode: 'quick-edit',
}),
).toBe('快速编辑生成占位图');
expect(getGenerationFrameLabel({ ...iconDialog, mode: 'quick-edit' })).toBe(
'Quick Edit Generator',
);
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
).toEqual({
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
],
references: [],
});
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '未授权访问(requestId: one',
status: 401,
code: 'UNAUTHORIZED',
}),
),
).toBe('请先登录后再生成图片');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '请求冲突(requestId: wallet-one',
status: 409,
code: 'CONFLICT',
details: { provider: 'profile-wallet' },
}),
),
).toBe('泥点余额不足,请充值后再生成');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '请求冲突(requestId: conflict-one',
status: 409,
code: 'CONFLICT',
}),
),
).toBe('生成任务未完成,请稍后重试');
});
});
const blankSpecValues = {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView: '',
customPrompt: '',
};
function buildSourceLayer(): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '原图',
src: '/source.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'uploaded',
};
}