阻止损坏生成配方降级恢复
保留生成配方 hydrate 来源状态 禁止损坏或未来版本配方回退 legacy 恢复 补齐资源快照与改造入口回归测试 记录已生成图层点击恢复浮层的产品待确认项 同步画布编辑器恢复契约文档
This commit is contained in:
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'
|
||||
|
||||
Reference in New Issue
Block a user