修复画布生成配方恢复边界

兼容生成配方未来字段与历史完美像素快照
补齐参数回退、旧版恢复告警和本地参考图上传
收紧生成任务幂等比较与裁扩来源归属
同步画布编辑器设计文档和回归测试
This commit is contained in:
2026-08-07 13:19:47 +08:00
parent 323051be29
commit 21b05f9446
13 changed files with 423 additions and 45 deletions
@@ -119,6 +119,47 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('ignores unknown nested V2 properties during non-strict resource hydration', () => {
expect(
generationInputsOrNull({
version: 2,
action: 'image.generate',
futureTopLevelProperty: true,
fields: [
{
id: 'prompt',
title: '生成提示词',
value: '未来配方',
futureFieldProperty: 'ignored',
},
],
references: [
{
id: 'reference',
title: '参考图',
label: '未来素材',
refType: 'asset',
refId: 'asset-future',
futureReferenceProperty: 1,
},
],
}),
).toEqual({
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }],
references: [
{
id: 'reference',
title: '参考图',
label: '未来素材',
refType: 'asset',
refId: 'asset-future',
},
],
});
});
it('fails malformed V2 closed while preserving legacy hydration', () => {
expect(
generationInputsOrNull({
@@ -1491,6 +1532,34 @@ describe('ImageCanvasEditorModel', () => {
);
});
it('keeps legacy perfect-pixel snapshots with empty string metadata recoverable', () => {
const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata';
const operation = buildPerfectPixelOperation(dialogId);
operation.request.generationInputs = {
fields: [{ title: '', value: '' }],
references: [
{
title: '',
label: '',
refType: 'asset',
refId: '',
},
],
};
const hydrated = hydrateCanvasGenerationDialog({
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
perfectPixelOperation: operation,
});
expect(hydrated?.perfectPixelOperation).toEqual(operation);
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
});
it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => {
// 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
@@ -171,6 +171,29 @@ describe('ImageCanvasExportModel', () => {
});
});
it('uses the reference fallback label when export metadata is blank', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
generationInputs: {
fields: [],
references: [
{
title: '参考图',
label: ' ',
refType: 'asset',
refId: 'asset-reference',
},
],
},
}),
'images/001-layer.png',
);
expect(metadata.visible.generationInputs?.references[0]?.label).toBe(
'参考素材',
);
});
it('filters built-in prompts from exported visible generation inputs', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
@@ -546,7 +546,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) {
const references =
layer.generationInputs?.references.map((reference) => ({
title: reference.title,
label: reference.label ?? '参考素材',
label: reference.label?.trim() || '参考素材',
refType: reference.refType,
refId: reference.refId,
})) ?? [];
@@ -24,21 +24,20 @@ export const CANVAS_GENERATION_ACTIONS = [
'image.crop-expand',
] as const satisfies readonly CanvasGenerationAction[];
export const REMIXABLE_CANVAS_GENERATION_ACTIONS =
new Set<CanvasGenerationAction>([
'image.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',
]);
const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
'image.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',
]);
const CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
const V2_GENERATION_INPUT_KEYS = new Set([
@@ -95,8 +94,9 @@ export function isCanvasGenerationAction(
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
}
export function isNormalizedCanvasGenerationInputsStructure(
function hasNormalizedCanvasGenerationInputsStructure(
value: unknown,
strictWhitelist: boolean,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
@@ -114,7 +114,7 @@ export function isNormalizedCanvasGenerationInputsStructure(
value.fields.every(
(field) =>
isRecord(field) &&
hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) &&
(!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) &&
isPresentString(field.id) &&
typeof field.title === 'string' &&
isGenerationInputValue(field.value),
@@ -122,7 +122,8 @@ export function isNormalizedCanvasGenerationInputsStructure(
value.references.every(
(reference) =>
isRecord(reference) &&
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) &&
(!strictWhitelist ||
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) &&
isPresentString(reference.id) &&
typeof reference.title === 'string' &&
(reference.label === undefined ||
@@ -134,21 +135,46 @@ export function isNormalizedCanvasGenerationInputsStructure(
);
}
export function isNormalizedCanvasGenerationInputsStructure(
value: unknown,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
} {
return hasNormalizedCanvasGenerationInputsStructure(value, true);
}
export function isRemixableCanvasGenerationAction(
action: CanvasGenerationAction,
) {
return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action);
}
function cloneV2GenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
!isNormalizedCanvasGenerationInputsStructure(value)
!hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist)
) {
return null;
}
return {
version: 2,
action: value.action,
fields: value.fields.map((field) => ({ ...field })),
references: value.references.map((reference) => ({ ...reference })),
fields: value.fields.map((field) => ({
id: field.id,
title: field.title,
value: field.value,
})),
references: value.references.map((reference) => ({
id: reference.id,
title: reference.title,
...(reference.label === undefined ? {} : { label: reference.label }),
refType: reference.refType,
refId: reference.refId,
})),
};
}
@@ -170,32 +196,42 @@ function hydrateLegacyGenerationInputs(
if (
!isRecord(field) ||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
!isPresentString(field.title) ||
!isPresentString(field.value)
(strictWhitelist
? typeof field.title !== 'string'
: !isPresentString(field.title)) ||
(strictWhitelist
? typeof field.value !== 'string'
: !isPresentString(field.value))
) {
return [];
}
return [{ title: field.title, value: field.value }];
return [{ title: field.title as string, value: field.value as string }];
});
const references = rawReferences.flatMap((reference) => {
if (
!isRecord(reference) ||
(strictWhitelist &&
!hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) ||
!isPresentString(reference.title) ||
!isPresentString(reference.label) ||
(strictWhitelist
? typeof reference.title !== 'string'
: !isPresentString(reference.title)) ||
(strictWhitelist
? typeof reference.label !== 'string'
: !isPresentString(reference.label)) ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
!isPresentString(reference.refId)
(strictWhitelist
? typeof reference.refId !== 'string'
: !isPresentString(reference.refId))
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
title: reference.title as string,
label: reference.label as string,
refType: reference.refType,
refId: reference.refId,
refId: reference.refId as string,
} satisfies CanvasGenerationInputReference,
];
});
@@ -591,7 +591,10 @@ describe('ImageCanvasGenerationModel', () => {
],
references: [],
});
expect(alias).toMatchObject({ ok: true, warnings: [] });
expect(alias).toMatchObject({
ok: true,
warnings: [expect.objectContaining({ fieldIds: ['style'] })],
});
expect(
alias.ok
? alias.inputs.fields.find((field) => field.id === 'model')?.value
@@ -706,6 +709,50 @@ describe('ImageCanvasGenerationModel', () => {
);
});
it('materializes and warns about missing action parameters', () => {
const decoded = decodeCanvasGenerationInputs({
version: 2,
action: 'video.generate',
fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }],
references: [],
});
expect(decoded).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({ fieldIds: ['model'] }),
expect.objectContaining({ fieldIds: ['durationSeconds'] }),
]),
});
const animation = decodeCanvasGenerationInputs({
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
references: [],
});
expect(animation).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'frameCount', value: 32 }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({
fieldIds: ['frameCount', 'durationSeconds'],
}),
]),
});
});
it('falls invalid sound and character animation parameters back to complete defaults', () => {
const sound = decodeCanvasGenerationInputs({
version: 2,
@@ -25,7 +25,7 @@ import type {
} from './ImageCanvasEditorTypes';
import {
isNormalizedCanvasGenerationInputsStructure,
REMIXABLE_CANVAS_GENERATION_ACTIONS,
isRemixableCanvasGenerationAction,
} from './ImageCanvasGenerationInputsModel';
import {
getPublicationMaterialsWorkflow,
@@ -1259,9 +1259,14 @@ export function decodeCanvasGenerationInputs(
title: string,
fallback: T,
resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined,
materializeMissing = true,
): T => {
const index = findFieldIndex(id);
if (index < 0) {
if (materializeMissing) {
setFieldValue(id, title, fallback);
addFallbackWarning([id]);
}
return fallback;
}
const resolved = resolve(fields[index]!.value);
@@ -1274,8 +1279,12 @@ export function decodeCanvasGenerationInputs(
return resolved;
};
const normalizeStringField = (id: string, title: string, fallback = '') =>
normalizeExistingField(id, title, fallback, (fieldValue) =>
typeof fieldValue === 'string' ? fieldValue : undefined,
normalizeExistingField(
id,
title,
fallback,
(fieldValue) => (typeof fieldValue === 'string' ? fieldValue : undefined),
false,
);
const normalizeStringOption = <T extends string>(
id: string,
@@ -1492,6 +1501,11 @@ export function decodeCanvasGenerationInputs(
);
addFallbackWarning(['frameCount', 'durationSeconds']);
}
} else {
const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0];
setFieldValue('frameCount', '帧数', defaultDuration.frameCount);
setFieldValue('durationSeconds', '时长', defaultDuration.durationSeconds);
addFallbackWarning(['frameCount', 'durationSeconds']);
}
}
@@ -1553,7 +1567,7 @@ export function canOpenRedrawPanel(
}
const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs);
if (decodedInputs.ok) {
if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) {
return false;
}
if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
@@ -1065,6 +1065,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
});
it('uploads local quick-edit references before submitting the edit request', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '参考局部素材修图' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit-local-reference',
mode: 'quick-edit',
prompt: '参考局部素材修图',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
generationReferences: [
{
id: 'local-reference',
label: '本地参考图',
src: 'data:image/png;base64,bG9jYWwtcmVmZXJlbmNl',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
}),
);
});
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
});
it('refreshes the wallet balance after inline image generation succeeds', async () => {
const refreshWalletBalance = vi.fn();
editEditorImageMock.mockResolvedValueOnce(
@@ -2062,6 +2062,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
);
}
const normalizedReferenceImageSrcs = await Promise.all(
(submissionPlan.editInput.referenceImageSrcs ?? []).map(
(referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
dialog.generationReferences?.[index]
? {
...dialog.generationReferences[index],
src: referenceImageSrc,
}
: { src: referenceImageSrc },
'image',
projectId,
),
),
);
const quickEditPlaceholderSize =
getCanvasCompletionPlaceholderSizeFromPlan({
sourceLayer: submissionPlan.sourceLayer,
@@ -2072,6 +2087,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
...submissionPlan.editInput,
...(normalizedReferenceImageSrcs.length
? { referenceImageSrcs: normalizedReferenceImageSrcs }
: {}),
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
@@ -4938,7 +4938,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
expect(screen.getByTestId('generation-references').textContent).toBe('');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
`${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`,
);
});
@@ -5001,6 +5001,29 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('warns when a legacy recipe is restored successfully', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: {
fields: [{ title: '生成提示词', value: '旧版森林场景' }],
references: [],
},
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).not.toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
});
it('does not legacy-fallback when a V2 required source is unavailable', () => {
render(
<GenerationWorkflowHarness
@@ -158,7 +158,7 @@ function getCanvasGenerationRedrawWarning({
if (hasParameterFallbacks) {
return CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING;
}
if (!isNormalizedGenerationInputs && hasUnavailableReferences) {
if (!isNormalizedGenerationInputs) {
return '已按旧版数据恢复,部分参数可能使用当前默认值。';
}
if (hasUnavailableReferences) {