阻止损坏生成配方降级恢复

保留生成配方 hydrate 来源状态

禁止损坏或未来版本配方回退 legacy 恢复

补齐资源快照与改造入口回归测试

记录已生成图层点击恢复浮层的产品待确认项

同步画布编辑器恢复契约文档
This commit is contained in:
2026-08-07 20:25:42 +08:00
parent 2a19ee1e27
commit e99f3209f1
10 changed files with 169 additions and 13 deletions
File diff suppressed because one or more lines are too long
@@ -187,6 +187,49 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('preserves an invalid versioned resource marker instead of falling back to layout legacy inputs', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-invalid-versioned-recipe',
resourceId: 'resource-invalid-versioned-recipe',
title: '损坏配方生成图',
src: '/layout/fallback.png',
x: 0,
y: 0,
width: 320,
height: 320,
originalWidth: 320,
originalHeight: 320,
zIndex: 1,
sourceType: 'generated',
generationInputs: {
fields: [{ title: '生成提示词', value: '不得当作旧版恢复' }],
references: [],
},
},
new Map([
[
'resource-invalid-versioned-recipe',
{
imageSrc: '/resource/generated.png',
sourceType: 'generated',
generationInputs: {
version: 3,
action: 'image.generate',
fields: [],
references: [],
},
},
],
]),
);
expect(hydrated).toMatchObject({
generationInputs: null,
generationInputsHydrationState: 'invalid-versioned',
});
});
it('keeps the resource default kind separate from a layer override', () => {
const layer = {
id: 'layer-shared',
@@ -21,7 +21,10 @@ import type {
PerfectPixelOperationSnapshot,
SnapCandidate,
} from './ImageCanvasEditorTypes';
import { hydrateCanvasGenerationInputs } from './ImageCanvasGenerationInputsModel';
import {
hydrateCanvasGenerationInputs,
hydrateCanvasGenerationInputsResult,
} from './ImageCanvasGenerationInputsModel';
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{
@@ -215,6 +218,7 @@ export function createLayerFromAsset(
assetKindOverride: null,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
generationInputsHydrationState: asset.generationInputsHydrationState,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
} satisfies CanvasLayer;
@@ -1431,6 +1435,19 @@ export function hydrateLayer(
imageSequenceDurationMs =
audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined;
}
const resourceGenerationInputs = hydrateCanvasGenerationInputsResult(
resource?.generationInputs,
);
const snapshotGenerationInputs = hydrateCanvasGenerationInputsResult(
snapshot.generationInputs,
);
// 资源行是正式配方来源。它若明确是损坏/未来版本的 V2,不能因布局中的旧快照或
// legacy fallback 被掩盖;只有资源值真正 absent 时才读取布局快照。
const generationInputsHydration = isFormalCharacterAnimation
? resourceGenerationInputs
: resourceGenerationInputs.state === 'absent'
? snapshotGenerationInputs
: resourceGenerationInputs;
return {
id: layerId,
resourceId,
@@ -1501,10 +1518,8 @@ export function hydrateLayer(
resourceAssetKind,
assetKindOverride,
assetKind,
generationInputs: isFormalCharacterAnimation
? generationInputsOrNull(resource?.generationInputs)
: (generationInputsOrNull(resource?.generationInputs) ??
generationInputsOrNull(snapshot.generationInputs)),
generationInputs: generationInputsHydration.inputs,
generationInputsHydrationState: generationInputsHydration.state,
hidden: booleanFromSnapshot(snapshot.hidden),
locked: booleanFromSnapshot(snapshot.locked),
flipX: booleanFromSnapshot(snapshot.flipX),
@@ -1721,6 +1736,8 @@ export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
showcaseLikeCount: asset.showcaseLikeCount ?? null,
assetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs).state,
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
};
@@ -61,6 +61,7 @@ export type EditorAsset = {
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
uploadStatus?: 'uploading' | 'failed';
@@ -109,6 +110,13 @@ export type CanvasGenerationInputs = {
references: CanvasGenerationInputReference[];
};
// hydrate 后的客户端运行时来源状态;不属于 generationInputs 持久化契约。
// 用于区分真正缺少配方的 legacy 数据与损坏/未来版本的版本化配方。
export type CanvasGenerationInputsHydrationState =
| 'absent'
| 'valid'
| 'invalid-versioned';
export type CanvasLayer = {
id: string;
resourceId: string;
@@ -141,6 +149,7 @@ export type CanvasLayer = {
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
@@ -43,6 +43,7 @@ import {
resolveContextMenuPosition,
resolveLayerResourceAssetKind,
} from './ImageCanvasEditorModel';
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type {
AssetPointerDragState,
@@ -306,6 +307,7 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? null,
generationInputs: asset.generationInputs ?? null,
generationInputsHydrationState: asset.generationInputsHydrationState,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
};
@@ -1113,6 +1115,9 @@ export function ImageCanvasEditorView({
asset.imageSequenceDurationMs ?? undefined,
assetKind: persistedAssetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs)
.state,
},
]);
setAssetFolders((currentFolders) =>
@@ -1185,6 +1190,8 @@ export function ImageCanvasEditorView({
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs).state,
},
]);
setAssetFolders((currentFolders) =>
@@ -1854,6 +1861,12 @@ export function ImageCanvasEditorView({
});
const openLayerGenerationDialog = useCallback(
(layer: CanvasLayer) => {
if (layer.generationInputsHydrationState === 'invalid-versioned') {
showGenerationWarning(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
return false;
}
const existingDialog = [...canvasGenerationDialogsRef.current]
.reverse()
.find((dialog) => dialog.generatedLayerId === layer.id);
@@ -1887,10 +1900,14 @@ export function ImageCanvasEditorView({
openCanvasGenerationDialog,
setSelectedLayerId,
setSelectedLayerIds,
showGenerationWarning,
],
);
const openGeneratedLayerGenerationDialog = useCallback(
(layer: CanvasLayer) => {
// TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层?
// 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式
// 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。
if (layer.sourceType !== 'generated') {
return false;
}
@@ -60,6 +60,22 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
}
describe('ImageCanvasGenerationDialogModel', () => {
it('does not legacy-fallback from a hydrated invalid versioned recipe', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
generationInputs: null,
generationInputsHydrationState: 'invalid-versioned',
prompt: '不能误当旧版配方',
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toBeNull();
});
it('creates centered drafts for image and spec generation dialogs', () => {
const canvasSize = { width: 1000, height: 800 };
const viewport = { x: 100, y: 40, scale: 2 };
@@ -1416,6 +1416,9 @@ export function createSameSourceGenerationDialogDraft({
CanvasGenerationDialogState,
'id'
> | null {
if (sourceLayer.generationInputsHydrationState === 'invalid-versioned') {
return null;
}
const normalizedDraft = createNormalizedGenerationDialogDraft({
sourceLayer,
canvasSize,
@@ -3,6 +3,7 @@ import type {
CanvasGenerationInputField,
CanvasGenerationInputReference,
CanvasGenerationInputs,
CanvasGenerationInputsHydrationState,
} from './ImageCanvasEditorTypes';
export const CANVAS_GENERATION_ACTIONS = [
@@ -250,15 +251,39 @@ function hydrateLegacyGenerationInputs(
: null;
}
export type CanvasGenerationInputsHydrationResult = {
state: CanvasGenerationInputsHydrationState;
inputs: CanvasGenerationInputs | null;
};
export function hydrateCanvasGenerationInputsResult(
value: unknown,
options: { strictWhitelist?: boolean } = {},
): CanvasGenerationInputsHydrationResult {
if (!isRecord(value)) {
return { state: 'absent', inputs: null };
}
if ('version' in value || 'action' in value) {
const inputs = cloneV2GenerationInputs(
value,
options.strictWhitelist === true,
);
return inputs
? { state: 'valid', inputs }
: { state: 'invalid-versioned', inputs: null };
}
const inputs = hydrateLegacyGenerationInputs(
value,
options.strictWhitelist === true,
);
return inputs
? { state: 'valid', inputs }
: { state: 'absent', inputs: null };
}
export function hydrateCanvasGenerationInputs(
value: unknown,
options: { strictWhitelist?: boolean } = {},
): CanvasGenerationInputs | null {
if (!isRecord(value)) {
return null;
}
if ('version' in value || 'action' in value) {
return cloneV2GenerationInputs(value, options.strictWhitelist === true);
}
return hydrateLegacyGenerationInputs(value, options.strictWhitelist === true);
return hydrateCanvasGenerationInputsResult(value, options).inputs;
}
@@ -5073,6 +5073,28 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('does not legacy-fallback when hydration retained an invalid versioned recipe', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: null,
generationInputsHydrationState: 'invalid-versioned',
prompt: '这不是可恢复的旧版配方',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
});
it('warns when a legacy recipe is restored successfully', () => {
render(
<GenerationWorkflowHarness
@@ -1830,6 +1830,7 @@ export function useImageCanvasGenerationWorkflow({
sourceLayer.generationInputs,
);
const hasInvalidVersionedGenerationInputs = Boolean(
sourceLayer.generationInputsHydrationState === 'invalid-versioned' ||
sourceLayer.generationInputs &&
(sourceLayer.generationInputs.version !== undefined ||
sourceLayer.generationInputs.action !== undefined) &&
@@ -1875,6 +1876,9 @@ export function useImageCanvasGenerationWorkflow({
const sourceReference = normalizedGenerationInputs.references.find(
(reference) => reference.id === 'source',
);
// 同一 resourceId/sourceAssetId 的画布副本引用同一份媒体数据;UI 素材提取的
// 框选覆盖层落在任一副本均可,恢复不依赖原 placement 的坐标、尺寸或层级,故按
// 当前图层顺序取首个匹配项即可,无需为此持久化 canvas layer identity。
const extractionSource = sourceReference
? layers.find((layer) =>
sourceReference.refType === 'project-resource'