收紧画布生成输入V2参数恢复

新增按action统一解码生成输入参数
将失效与不兼容参数回落到当前默认值
恢复和再次提交共用规范V2结果并展示告警
记录原地收紧决策与Review处理结果
补充历史模型和非法参数回归测试
This commit is contained in:
2026-08-04 16:24:31 +08:00
parent a9b462d43f
commit 96b06cc39b
8 changed files with 667 additions and 21 deletions
@@ -16,6 +16,17 @@
---
## 2026-08-04 画布生成输入 V2 原地收紧并统一回落当前默认值
- 背景:画布 `generationInputs.version=2` 从持久化 JSON 恢复时只校验通用结构,未知、非法、已下线或与当前模型能力不兼容的模型、比例、尺寸、清晰度、声音、时长及角色动作档位仍可通过 TypeScript 断言进入 UI 和再次提交。前端已隐藏但后端仍兼容的历史 Veo 也不再属于当前可选模型。
- 决策:不新增 V3,不迁移数据库,不保留旧值再次执行;V2 在读取时原地按 `action` 解码。所有已存在但未知、非法、已下线或与当前模型不兼容的参数统一回落到该 action 的当前默认值,历史 Veo 同样回落到当前默认视频模型。图片比例 / 尺寸按回落后的模型联动校验,视频参数按当前模型能力校验,角色动作 `frameCount / durationSeconds` 按完整档位成对校验。发生回落时必须向用户显示“部分原生成参数已使用当前默认值”告警;再次提交只使用规范结果并保存为仍是 `version: 2` 的新快照。缺失字段继续由当前 action 默认值补齐,不为此单独升级版本。
- 实现边界:单一 action 级 runtime decoder 是持久化 V2 的读取真相,恢复 UI、改造入口和再次提交链不得各自解释原始 JSON;canonical 选项从当前编辑器模型 / 参数注册表派生,不新增平行旧模型清单。通用结构不合法时整份配方不支持改造;必需 `source` 缺失时仍拒绝改造,不用默认值伪造引用。该策略只改变画布配方的运行时恢复和后续重存,不修改 SpacetimeDB schema、BFF DTO 或已有资产原始 JSON。
- 影响范围:`ImageCanvasGenerationModel` 的 V2 decoder、`ImageCanvasGenerationDialogModel` 的恢复入口、`useImageCanvasGenerationWorkflow` 的回落告警、生成输入回归测试和编辑器 Lovart 统一方案。
- 验证方式:fixture 覆盖当前合法 V2、历史 Veo、未知模型、模型不兼容尺寸、非法视频参数、非法音效参数、角色动作错配档位和缺失必需来源;断言 UI、价格与提交使用规范值,回落显示告警,再次生成仍保存 V2。运行图片画布定向 Vitest、`npm run typecheck`、定向 ESLint、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``review.txt`
---
## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存
- 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。
@@ -113,7 +113,7 @@
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -886,7 +886,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '角色追逐镜头',
videoModel: 'seedance2.0-fast',
videoAspectRatio: '9:16',
videoResolution: '1080p',
videoResolution: '480p',
videoDurationSeconds: 8,
videoSound: 'off',
videoWebSearchEnabled: false,
@@ -19,6 +19,7 @@ import {
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
@@ -806,10 +807,13 @@ export function getUnavailableNormalizedGenerationInputReferences(
sourceLayer: CanvasLayer,
availableLayers: CanvasLayer[] | undefined,
) {
if (!isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
const decodedInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
if (!decodedInputs.ok) {
return [];
}
return sourceLayer.generationInputs.references.filter(
return decodedInputs.inputs.references.filter(
(reference) =>
!findAvailableGenerationReferenceLayer(reference, availableLayers),
);
@@ -832,7 +836,7 @@ function resolveNormalizedReferences(
}
function createNormalizedGenerationDialogDraft({
sourceLayer,
sourceLayer: persistedSourceLayer,
canvasSize,
viewport,
availableLayers,
@@ -840,10 +844,17 @@ function createNormalizedGenerationDialogDraft({
CanvasGenerationDialogState,
'id'
> | null {
const inputs = sourceLayer.generationInputs;
if (!isNormalizedCanvasGenerationInputs(inputs)) {
const decodedInputs = decodeCanvasGenerationInputs(
persistedSourceLayer.generationInputs,
);
if (!decodedInputs.ok) {
return null;
}
const inputs = decodedInputs.inputs;
const sourceLayer: CanvasLayer = {
...persistedSourceLayer,
generationInputs: inputs,
};
const prompt = getNormalizedString(sourceLayer, 'prompt');
const references = resolveNormalizedReferences(
sourceLayer,
@@ -29,7 +29,9 @@ import {
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
canOpenRedrawPanel,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_MODEL,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
@@ -543,6 +545,195 @@ describe('ImageCanvasGenerationModel', () => {
});
});
it('keeps valid V2 recipes and supported image aliases canonical', () => {
const valid = decodeCanvasGenerationInputs(
buildImageGenerationInputs('森林城堡', [], {
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
}),
);
expect(valid).toMatchObject({ ok: true, warnings: [] });
const alias = decodeCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '像素村庄' },
{ id: 'model', title: '模型', value: 'nanobanana2' },
{ id: 'aspectRatio', title: '比例', value: '4:3' },
{ id: 'imageSize', title: '尺寸', value: '0.5K' },
],
references: [],
});
expect(alias).toMatchObject({ ok: true, warnings: [] });
expect(
alias.ok
? alias.inputs.fields.find((field) => field.id === 'model')?.value
: null,
).toBe(IMAGE_MODEL_NANOBANANA2);
});
it('falls invalid image and workflow parameters back within V2', () => {
const image = decodeCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '森林城堡' },
{ id: 'model', title: '模型', value: 'removed-image-model' },
{ id: 'style', title: '风格', value: 'oil' },
{ id: 'aspectRatio', title: '比例', value: '7:5' },
{ id: 'imageSize', title: '尺寸', value: '8K' },
],
references: [],
});
expect(image.ok).toBe(true);
if (!image.ok) {
throw new Error('图片 V2 解码失败');
}
expect(
Object.fromEntries(
image.inputs.fields.map((field) => [field.id, field.value]),
),
).toMatchObject({
model: DEFAULT_IMAGE_MODEL,
style: 'none',
aspectRatio: '1:1',
imageSize: '1K',
});
expect(image.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({ fieldIds: ['model'] }),
expect.objectContaining({ fieldIds: ['style'] }),
expect.objectContaining({ fieldIds: ['aspectRatio'] }),
expect.objectContaining({ fieldIds: ['imageSize'] }),
]),
);
expect(image.warnings[0]?.message).toBe(
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
);
const workflow = decodeCanvasGenerationInputs({
version: 2,
action: 'publication.generate',
fields: [
{ id: 'workflowId', title: '宣发类型', value: 'retired-workflow' },
],
references: [],
});
expect(workflow).toMatchObject({
ok: true,
inputs: {
version: 2,
action: 'publication.generate',
fields: [
{
id: 'workflowId',
title: '宣发类型',
value: 'publication-cover-image',
},
],
},
warnings: [expect.objectContaining({ fieldIds: ['workflowId'] })],
});
});
it('falls retired and incompatible video parameters back to current defaults', () => {
const decoded = decodeCanvasGenerationInputs({
version: 2,
action: 'video.generate',
fields: [
{ id: 'prompt', title: '视频描述', value: '追逐镜头' },
{ id: 'model', title: '模型', value: 'veo3.1' },
{ id: 'aspectRatio', title: '比例', value: '7:5' },
{ id: 'resolution', title: '清晰度', value: '1080p' },
{ id: 'durationSeconds', title: '时长', value: 99 },
{ id: 'sound', title: '声音', value: 'surround' },
{ id: 'webSearchEnabled', title: '联网搜索', value: 'yes' },
],
references: [],
});
expect(decoded.ok).toBe(true);
if (!decoded.ok) {
throw new Error('视频 V2 解码失败');
}
expect(
Object.fromEntries(
decoded.inputs.fields.map((field) => [field.id, field.value]),
),
).toMatchObject({
model: DEFAULT_VIDEO_MODEL,
aspectRatio: '16:9',
resolution: '480p',
durationSeconds: 4,
sound: 'on',
webSearchEnabled: true,
});
expect(decoded.warnings.map((warning) => warning.fieldIds)).toEqual(
expect.arrayContaining([
['model'],
['aspectRatio'],
['resolution'],
['durationSeconds'],
['sound'],
['webSearchEnabled'],
]),
);
});
it('falls invalid sound and character animation parameters back to complete defaults', () => {
const sound = decodeCanvasGenerationInputs({
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ id: 'prompt', title: 'prompt', value: '点击音' },
{ id: 'model', title: 'model', value: 'retired-audio-model' },
{ id: 'durationSeconds', title: 'duration', value: 11 },
],
references: [],
});
expect(sound).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'model', value: 'audio1.0' }),
expect.objectContaining({ id: 'durationSeconds', value: 5 }),
]),
},
});
const animation = decodeCanvasGenerationInputs({
version: 2,
action: 'character-animation.generate',
fields: [
{ id: 'prompt', title: '动作描述', value: '挥手' },
{ id: 'resolution', title: '清晰度', value: '1080p' },
{ id: 'ratio', title: '比例', value: '7:5' },
{ id: 'frameCount', title: '帧数', value: 40 },
{ id: 'durationSeconds', title: '时长', value: 6 },
],
references: [],
});
expect(animation).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'resolution', value: '480p' }),
expect.objectContaining({ id: 'ratio', value: 'same' }),
expect.objectContaining({ id: 'frameCount', value: 32 }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({ fieldIds: ['resolution'] }),
expect.objectContaining({ fieldIds: ['ratio'] }),
expect.objectContaining({
fieldIds: ['frameCount', 'durationSeconds'],
}),
]),
});
});
it('keeps generated prompts and quick edit options stable', () => {
const prompt = buildSpecPrompt('ui', {
playSetting: '消除玩法',
@@ -1111,6 +1111,351 @@ export function isNormalizedCanvasGenerationInputs(
);
}
type NormalizedCanvasGenerationInputs = CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
};
export type CanvasGenerationInputDecodeWarning = {
code: 'parameter-fallback';
fieldIds: string[];
message: string;
};
export type CanvasGenerationInputDecodeResult =
| {
ok: true;
inputs: NormalizedCanvasGenerationInputs;
warnings: CanvasGenerationInputDecodeWarning[];
}
| {
ok: false;
inputs: null;
warnings: [];
error: 'invalid-structure';
};
export const CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING =
'部分原生成参数已失效或不受支持,已使用当前默认值。';
export function decodeCanvasGenerationInputs(
value: CanvasGenerationInputs | null | undefined,
): CanvasGenerationInputDecodeResult {
if (!isNormalizedCanvasGenerationInputs(value)) {
return {
ok: false,
inputs: null,
warnings: [],
error: 'invalid-structure',
};
}
const fields = value.fields.map((field) => ({ ...field }));
const warnings: CanvasGenerationInputDecodeWarning[] = [];
const warnedFieldGroups = new Set<string>();
const addFallbackWarning = (fieldIds: string[]) => {
const key = [...fieldIds].sort().join('|');
if (warnedFieldGroups.has(key)) {
return;
}
warnedFieldGroups.add(key);
warnings.push({
code: 'parameter-fallback',
fieldIds,
message: CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
});
};
const findFieldIndex = (id: string) =>
fields.findIndex((field) => field.id === id);
const setFieldValue = (
id: string,
title: string,
fieldValue: CanvasGenerationInputValue,
) => {
const index = findFieldIndex(id);
if (index >= 0) {
fields[index] = { ...fields[index]!, value: fieldValue };
return;
}
fields.push({ id, title, value: fieldValue });
};
const normalizeExistingField = <T extends CanvasGenerationInputValue>(
id: string,
title: string,
fallback: T,
resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined,
): T => {
const index = findFieldIndex(id);
if (index < 0) {
return fallback;
}
const resolved = resolve(fields[index]!.value);
if (resolved === undefined) {
setFieldValue(id, title, fallback);
addFallbackWarning([id]);
return fallback;
}
setFieldValue(id, title, resolved);
return resolved;
};
const normalizeStringField = (id: string, title: string, fallback = '') =>
normalizeExistingField(
id,
title,
fallback,
(fieldValue) =>
typeof fieldValue === 'string' ? fieldValue : undefined,
);
const normalizeStringOption = <T extends string>(
id: string,
title: string,
options: readonly T[],
fallback: T,
normalize: (fieldValue: string) => string = (fieldValue) => fieldValue,
) =>
normalizeExistingField(id, title, fallback, (fieldValue) => {
if (typeof fieldValue !== 'string') {
return undefined;
}
const normalized = normalize(fieldValue);
return options.includes(normalized as T) ? (normalized as T) : undefined;
});
const normalizeNumberOption = <T extends number>(
id: string,
title: string,
options: readonly T[],
fallback: T,
) =>
normalizeExistingField(id, title, fallback, (fieldValue) =>
typeof fieldValue === 'number' && options.includes(fieldValue as T)
? (fieldValue as T)
: undefined,
);
const normalizeBooleanField = (
id: string,
title: string,
fallback: boolean,
) =>
normalizeExistingField(id, title, fallback, (fieldValue) =>
typeof fieldValue === 'boolean' ? fieldValue : undefined,
);
const normalizeImageParameters = ({
defaultModel,
defaultAspectRatio,
fixedAspectRatios,
}: {
defaultModel: string;
defaultAspectRatio: string;
fixedAspectRatios?: readonly string[];
}) => {
const imageModels = EDITOR_IMAGE_MODEL_OPTIONS.map(
(option) => option.value,
);
const model = normalizeStringOption(
'model',
'模型',
imageModels,
defaultModel,
normalizeEditorImageModel,
);
const dimensions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ??
EDITOR_IMAGE_DIMENSION_OPTIONS[
defaultModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
];
const aspectRatios =
fixedAspectRatios ?? (dimensions.aspectRatios as readonly string[]);
const resolvedDefaultAspectRatio = aspectRatios.includes(defaultAspectRatio)
? defaultAspectRatio
: (aspectRatios[0] ?? '1:1');
const imageSizes = dimensions.imageSizes as readonly string[];
const defaultImageSize = imageSizes.includes('1K')
? '1K'
: (imageSizes[0] ?? '1K');
normalizeStringOption(
'aspectRatio',
'比例',
aspectRatios,
resolvedDefaultAspectRatio,
);
normalizeStringOption(
'imageSize',
'尺寸',
imageSizes,
defaultImageSize,
);
};
const action = value.action;
if (
action === 'image.generate' ||
action === 'character.generate' ||
action === 'icon.generate'
) {
normalizeStringField('prompt', '提示词');
normalizeStringOption('style', '风格', ['none', 'pixelArt'], 'none');
normalizeImageParameters({
defaultModel: DEFAULT_IMAGE_MODEL,
defaultAspectRatio: '1:1',
});
} else if (action === 'ui-design.generate') {
normalizeStringField('prompt', '用户输入');
normalizeImageParameters({
defaultModel: IMAGE_MODEL_GPT_IMAGE_2,
defaultAspectRatio: '16:9',
});
} else if (action === 'image.edit') {
normalizeStringField('prompt', '修改要求');
normalizeImageParameters({
defaultModel: DEFAULT_IMAGE_MODEL,
defaultAspectRatio: '1:1',
});
} else if (action === 'ui-design.extract-assets') {
normalizeStringField('prompt', '提取提示词');
normalizeImageParameters({
defaultModel: DEFAULT_IMAGE_MODEL,
defaultAspectRatio: '1:1',
fixedAspectRatios: ['1:1'],
});
} else if (action === 'spec.generate') {
normalizeStringOption(
'specType',
'规范类型',
['character', 'ui', 'icon', 'custom'],
'custom',
);
normalizeStringField('playSetting', '玩法设定');
normalizeStringField('artStyle', '美术风格');
normalizeStringField('bodyRatio', '头身比');
normalizeStringField('characterView', '角色视角');
normalizeStringField('customPrompt', '自定义规范提示词');
} else if (action === 'publication.generate') {
normalizeStringOption(
'workflowId',
'宣发类型',
[
'publication-cover-image',
'publication-detail-gallery',
'publication-promo-poster',
],
'publication-cover-image',
);
normalizeStringField('gameName', '游戏名');
normalizeStringField('gameCategories', '游戏分类');
normalizeStringField('gameDescription', '一句话描述游戏');
} else if (action === 'video.generate') {
normalizeStringField('prompt', '视频描述');
const videoModel = normalizeStringOption(
'model',
'模型',
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value),
DEFAULT_VIDEO_MODEL,
);
normalizeStringOption(
'aspectRatio',
'比例',
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
DEFAULT_VIDEO_ASPECT_RATIO,
);
normalizeStringOption(
'resolution',
'清晰度',
videoModel === VIDEO_MODEL_SEEDANCE_2_FAST
? (['480p', '720p'] as const)
: EDITOR_VIDEO_RESOLUTION_OPTIONS,
'480p',
);
normalizeNumberOption(
'durationSeconds',
'时长',
EDITOR_VIDEO_DURATION_OPTIONS,
DEFAULT_VIDEO_DURATION_SECONDS,
);
normalizeStringOption(
'sound',
'声音',
['on', 'off'],
DEFAULT_VIDEO_SOUND,
);
normalizeBooleanField(
'webSearchEnabled',
'联网搜索',
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
);
} else if (action === 'audio.sound-effect.generate') {
normalizeStringField('prompt', 'prompt');
normalizeStringOption(
'model',
'model',
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value),
DEFAULT_SOUND_EFFECT_MODEL,
);
normalizeNumberOption(
'durationSeconds',
'duration',
SOUND_EFFECT_DURATION_OPTIONS,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
);
} else if (action === 'audio.background-music.generate') {
normalizeStringField('prompt', 'gpt_description_prompt');
} else if (action === 'character-animation.generate') {
normalizeStringField('prompt', '动作描述');
normalizeStringOption(
'resolution',
'清晰度',
['480p', '720p'],
'480p',
);
normalizeStringOption(
'ratio',
'比例',
CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => option.value),
'same',
);
const frameCountIndex = findFieldIndex('frameCount');
const durationIndex = findFieldIndex('durationSeconds');
if (frameCountIndex >= 0 || durationIndex >= 0) {
const frameCount =
frameCountIndex >= 0 ? fields[frameCountIndex]!.value : undefined;
const durationSeconds =
durationIndex >= 0 ? fields[durationIndex]!.value : undefined;
const durationOption = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
(option) =>
option.frameCount === frameCount &&
option.durationSeconds === durationSeconds,
);
if (!durationOption) {
const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0];
setFieldValue(
'frameCount',
'帧数',
defaultDuration.frameCount,
);
setFieldValue(
'durationSeconds',
'时长',
defaultDuration.durationSeconds,
);
addFallbackWarning(['frameCount', 'durationSeconds']);
}
}
}
return {
ok: true,
inputs: {
...value,
version: 2,
action,
fields,
references: value.references.map((reference) => ({ ...reference })),
},
warnings,
};
}
const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set(
[
'生成提示词',
@@ -1146,13 +1491,14 @@ export function canOpenRedrawPanel(
if (layer.sourceType === 'uploaded') {
return false;
}
if (isNormalizedCanvasGenerationInputs(layer.generationInputs)) {
const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs);
if (decodedInputs.ok) {
if (
!REQUIRED_SOURCE_GENERATION_ACTIONS.has(layer.generationInputs.action)
!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)
) {
return true;
}
const sourceReference = layer.generationInputs.references.find(
const sourceReference = decodedInputs.inputs.references.find(
(reference) => reference.id === 'source',
);
if (!sourceReference) {
@@ -18,7 +18,11 @@ import type {
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
import {
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
DEFAULT_IMAGE_MODEL,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
@@ -2307,6 +2311,65 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('warns and submits current defaults when V2 parameters are invalid', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '森林场景' }),
);
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
{ id: 'model', title: '模型', value: 'retired-image-model' },
{ id: 'aspectRatio', title: '比例', value: '7:5' },
{ id: 'imageSize', title: '尺寸', value: '8K' },
],
references: [],
},
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog-image-options').textContent).toBe(
`generate:${DEFAULT_IMAGE_MODEL}:1:1:1K`,
);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
);
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
model: DEFAULT_IMAGE_MODEL,
aspectRatio: '1:1',
imageSize: '1K',
generationInputs: expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'model',
value: DEFAULT_IMAGE_MODEL,
}),
expect.objectContaining({ id: 'aspectRatio', value: '1:1' }),
expect.objectContaining({ id: 'imageSize', value: '1K' }),
]),
}),
}),
);
});
});
it('does not legacy-fallback when a V2 required source is unavailable', () => {
render(
<GenerationWorkflowHarness
@@ -74,14 +74,15 @@ import {
} from './ImageCanvasGenerationDialogModel';
import {
calculateCharacterAnimationPrice,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isNormalizedCanvasGenerationInputs,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
@@ -1279,15 +1280,22 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel(null);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
const normalizedGenerationInputs = isNormalizedCanvasGenerationInputs(
const decodedGenerationInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
)
? sourceLayer.generationInputs
);
const normalizedGenerationInputs = decodedGenerationInputs.ok
? decodedGenerationInputs.inputs
: null;
const isNormalizedGenerationInputs = normalizedGenerationInputs !== null;
const hasParameterFallbacks = decodedGenerationInputs.ok
? decodedGenerationInputs.warnings.length > 0
: false;
const normalizedSourceLayer = normalizedGenerationInputs
? { ...sourceLayer, generationInputs: normalizedGenerationInputs }
: sourceLayer;
const unavailableReferences = isNormalizedGenerationInputs
? getUnavailableNormalizedGenerationInputReferences(
sourceLayer,
normalizedSourceLayer,
layers,
)
: [];
@@ -1337,7 +1345,15 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
selectSingleLayer(extractionSource.id);
setActiveTool('select');
if (unavailableReferences.length) {
if (hasParameterFallbacks && unavailableReferences.length) {
showGenerationWarning(
`${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} `,
);
} else if (hasParameterFallbacks) {
showGenerationWarning(
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
);
} else if (unavailableReferences.length) {
showGenerationWarning(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
);
@@ -1346,14 +1362,14 @@ export function useImageCanvasGenerationWorkflow({
}
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
normalizedSourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
sourceLayer,
normalizedSourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer,
sourceLayer: normalizedSourceLayer,
canvasSize,
viewport,
sourceDialog,
@@ -1366,7 +1382,15 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
if (isNormalizedGenerationInputs && unavailableReferences.length) {
if (hasParameterFallbacks && unavailableReferences.length) {
showGenerationWarning(
`${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} `,
);
} else if (hasParameterFallbacks) {
showGenerationWarning(
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
);
} else if (isNormalizedGenerationInputs && unavailableReferences.length) {
showGenerationWarning(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
);