修复画布生成配方恢复与快照失真

保持背景音乐规范提示词与生成输入快照一致。

统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。

为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。

由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。

补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。
This commit is contained in:
2026-08-06 19:44:57 +08:00
parent 706fa3ca8c
commit e2fbef79e1
18 changed files with 968 additions and 256 deletions
@@ -13,6 +13,7 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
dropDeadInlineGenerationPlaceholders,
formatCanvasDisplayScalePercent,
generationInputsOrNull,
hydrateCanvasGenerationDialog,
hydrateLayer,
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
@@ -82,6 +83,69 @@ function buildPerfectPixelOperation(
}
describe('ImageCanvasEditorModel', () => {
it('hydrates complete V2 generation inputs without coercing values or requiring labels', () => {
expect(
generationInputsOrNull({
version: 2,
action: 'video.generate',
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset',
refId: 'asset-video-reference',
},
],
}),
).toEqual({
version: 2,
action: 'video.generate',
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset',
refId: 'asset-video-reference',
},
],
});
});
it('fails malformed V2 closed while preserving legacy hydration', () => {
expect(
generationInputsOrNull({
version: 2,
fields: [{ title: '生成提示词', value: '不得降级' }],
references: [],
}),
).toBeNull();
expect(
generationInputsOrNull({
fields: [{ title: '生成提示词', value: '旧版配方' }],
references: [],
}),
).toEqual({
fields: [{ title: '生成提示词', value: '旧版配方' }],
references: [],
});
expect(
generationInputsOrNull({
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
}),
).toEqual({
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
references: [],
});
});
it('keeps the resource default kind separate from a layer override', () => {
const layer = {
id: 'layer-shared',
@@ -412,6 +476,43 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores V2 recipes from the persisted asset library after refresh', () => {
const generationInputs = {
version: 2 as const,
action: 'video.generate' as const,
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: true },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset' as const,
refId: 'asset-reference',
},
],
};
const library = normalizeAssetLibrary({
folders: [],
assets: [
{
assetId: 'asset-v2',
folderId: 'project',
label: 'V2 视频',
imageSrc: '/generated/video.mp4',
width: 1280,
height: 720,
sourceType: 'generated',
assetKind: 'video',
generationInputs,
},
],
});
expect(library.assets[0]?.generationInputs).toEqual(generationInputs);
});
it('round-trips an explicit character action into a movable sequence layer', () => {
const frames = [
{
@@ -837,6 +938,52 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores V2 recipes from project resources after refresh', () => {
const generationInputs = {
version: 2 as const,
action: 'video.generate' as const,
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'project-resource' as const,
refId: 'resource-reference',
},
],
};
const hydrated = hydrateLayer(
{
layerId: 'layer-v2',
resourceId: 'resource-v2',
title: 'V2 视频',
x: 0,
y: 0,
width: 1280,
height: 720,
originalWidth: 1280,
originalHeight: 720,
zIndex: 1,
sourceType: 'generated',
},
new Map([
[
'resource-v2',
{
imageSrc: '/generated/video.mp4',
assetKind: 'video',
generationInputs,
},
],
]),
);
expect(hydrated?.generationInputs).toEqual(generationInputs);
});
it('hydrates a layer override ahead of the shared resource default', () => {
const hydrated = hydrateLayer(
{
@@ -21,6 +21,7 @@ import type {
PerfectPixelOperationSnapshot,
SnapCandidate,
} from './ImageCanvasEditorTypes';
import { hydrateCanvasGenerationInputs } from './ImageCanvasGenerationInputsModel';
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{
@@ -425,15 +426,6 @@ const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([
'originalWidth',
'originalHeight',
]);
const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']);
const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -478,62 +470,6 @@ function isStableEditorMediaReference(value: unknown): value is string {
}
}
function hydratePerfectPixelGenerationInputs(
value: unknown,
): EditorAssetGenerationInputs | null {
if (
!isSnapshotRecord(value) ||
!hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return null;
}
const fields = value.fields.flatMap((field) => {
if (
!isSnapshotRecord(field) ||
!hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) ||
typeof field.title !== 'string' ||
typeof field.value !== 'string'
) {
return [];
}
return [{ title: field.title, value: field.value }];
});
const references: EditorAssetGenerationInputs['references'] =
value.references.flatMap((reference) => {
if (
!isSnapshotRecord(reference) ||
!hasOnlySnapshotKeys(
reference,
PERFECT_PIXEL_GENERATION_REFERENCE_KEYS,
) ||
typeof reference.title !== 'string' ||
typeof reference.label !== 'string' ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
typeof reference.refId !== 'string'
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
refType: reference.refType as 'project-resource' | 'asset',
refId: reference.refId,
},
];
});
if (
fields.length !== value.fields.length ||
references.length !== value.references.length
) {
return null;
}
return { fields, references };
}
/**
* 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。
* 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份;
@@ -606,9 +542,9 @@ export function hydratePerfectPixelOperation(
} else if (request.generationInputs === null) {
generationInputs = null;
} else {
generationInputs = hydratePerfectPixelGenerationInputs(
request.generationInputs,
);
generationInputs = hydrateCanvasGenerationInputs(request.generationInputs, {
strictWhitelist: true,
});
}
if (
request.generationInputs !== undefined &&
@@ -2164,49 +2100,7 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
export function generationInputsOrNull(
value: unknown,
): CanvasGenerationInputs | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
fields?: unknown;
references?: unknown;
};
const fields = Array.isArray(snapshot.fields)
? snapshot.fields.flatMap((field) => {
if (!field || typeof field !== 'object') {
return [];
}
const item = field as { title?: unknown; value?: unknown };
const title = stringOrNull(item.title);
const fieldValue = stringOrNull(item.value);
return title && fieldValue ? [{ title, value: fieldValue }] : [];
})
: [];
const references = Array.isArray(snapshot.references)
? snapshot.references.flatMap((reference) => {
if (!reference || typeof reference !== 'object') {
return [];
}
const item = reference as {
title?: unknown;
label?: unknown;
refType?: unknown;
refId?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const refType: 'project-resource' | 'asset' | null =
item.refType === 'project-resource' || item.refType === 'asset'
? item.refType
: null;
const refId = stringOrNull(item.refId);
return title && label && refType && refId
? [{ title, label, refType, refId }]
: [];
})
: [];
return fields.length || references.length ? { fields, references } : null;
return hydrateCanvasGenerationInputs(value);
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
@@ -80,7 +80,11 @@ export type CanvasGenerationAction =
| 'audio.background-music.generate'
| 'character-animation.generate'
| 'image.edit'
| 'ui-design.extract-assets';
| 'ui-design.extract-assets'
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand';
export type CanvasGenerationInputValue = string | number | boolean;
@@ -0,0 +1,225 @@
import type {
CanvasGenerationAction,
CanvasGenerationInputField,
CanvasGenerationInputReference,
CanvasGenerationInputs,
} from './ImageCanvasEditorTypes';
export const CANVAS_GENERATION_ACTIONS = [
'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',
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'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 CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
const V2_GENERATION_INPUT_KEYS = new Set([
'version',
'action',
'fields',
'references',
]);
const V2_GENERATION_FIELD_KEYS = new Set(['id', 'title', 'value']);
const V2_GENERATION_REFERENCE_KEYS = new Set([
'id',
'title',
'label',
'refType',
'refId',
]);
const LEGACY_GENERATION_INPUT_KEYS = new Set(['fields', 'references']);
const LEGACY_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const LEGACY_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOnlyKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
) {
return Object.keys(value).every((key) => allowedKeys.has(key));
}
function isPresentString(value: unknown): value is string {
return typeof value === 'string' && Boolean(value.trim());
}
function isGenerationInputValue(
value: unknown,
): value is CanvasGenerationInputField['value'] {
return (
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value)) ||
typeof value === 'boolean'
);
}
export function isCanvasGenerationAction(
value: unknown,
): value is CanvasGenerationAction {
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
}
export function isNormalizedCanvasGenerationInputsStructure(
value: unknown,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
} {
if (
!isRecord(value) ||
value.version !== 2 ||
!isCanvasGenerationAction(value.action) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return false;
}
return (
value.fields.every(
(field) =>
isRecord(field) &&
hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) &&
isPresentString(field.id) &&
typeof field.title === 'string' &&
isGenerationInputValue(field.value),
) &&
value.references.every(
(reference) =>
isRecord(reference) &&
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) &&
isPresentString(reference.id) &&
typeof reference.title === 'string' &&
(reference.label === undefined ||
typeof reference.label === 'string') &&
(reference.refType === 'project-resource' ||
reference.refType === 'asset') &&
isPresentString(reference.refId),
)
);
}
function cloneV2GenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
!isNormalizedCanvasGenerationInputsStructure(value)
) {
return null;
}
return {
version: 2,
action: value.action,
fields: value.fields.map((field) => ({ ...field })),
references: value.references.map((reference) => ({ ...reference })),
};
}
function hydrateLegacyGenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
strictWhitelist &&
(!hasOnlyKeys(value, LEGACY_GENERATION_INPUT_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references))
) {
return null;
}
const rawFields = Array.isArray(value.fields) ? value.fields : [];
const rawReferences = Array.isArray(value.references) ? value.references : [];
const fields = rawFields.flatMap((field) => {
if (
!isRecord(field) ||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
!isPresentString(field.title) ||
!isPresentString(field.value)
) {
return [];
}
return [{ title: field.title, value: field.value }];
});
const references = rawReferences.flatMap((reference) => {
if (
!isRecord(reference) ||
(strictWhitelist &&
!hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) ||
!isPresentString(reference.title) ||
!isPresentString(reference.label) ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
!isPresentString(reference.refId)
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
refType: reference.refType,
refId: reference.refId,
} satisfies CanvasGenerationInputReference,
];
});
if (
strictWhitelist &&
(fields.length !== rawFields.length ||
references.length !== rawReferences.length)
) {
return null;
}
return strictWhitelist || fields.length || references.length
? { fields, references }
: 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);
}
@@ -495,6 +495,31 @@ describe('ImageCanvasGenerationModel', () => {
generationInputs: buildImageGenerationInputs('上传文件'),
}),
).toBe(false);
for (const action of [
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const) {
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
action,
fields: [],
references: [],
},
}),
).toBe(false);
}
expect(
canOpenRedrawPanel({
...generatedLayer,
taskId: 'pixel-art-snap-legacy-dialog',
generationInputs: buildImageGenerationInputs('历史继承配方'),
}),
).toBe(false);
});
it('rejects malformed persisted generation metadata without throwing', () => {
@@ -23,6 +23,10 @@ import type {
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
isNormalizedCanvasGenerationInputsStructure,
REMIXABLE_CANVAS_GENERATION_ACTIONS,
} from './ImageCanvasGenerationInputsModel';
import {
getPublicationMaterialsWorkflow,
type PublicationMaterialsWorkflow,
@@ -1110,6 +1114,25 @@ export function createLayerGenerationInputReference(
);
}
export function buildDeterministicGenerationInputs(
action:
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand',
sourceLayer: CanvasLayer,
sourceTitle = '原图',
): CanvasGenerationInputs {
return {
version: 2,
action,
fields: [],
references: createLayerGenerationInputReference(sourceTitle, sourceLayer, {
id: 'source',
}),
};
}
export function appendLimitedQuickEditReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
@@ -1154,57 +1177,13 @@ export function formatGenerationInputValue(value: CanvasGenerationInputValue) {
return typeof value === 'string' ? value : String(value);
}
const 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',
]);
export function isNormalizedCanvasGenerationInputs(
value: CanvasGenerationInputs | null | undefined,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
} {
return Boolean(
value?.version === 2 &&
value.action &&
CANVAS_GENERATION_ACTIONS.has(value.action) &&
Array.isArray(value.fields) &&
Array.isArray(value.references) &&
value.fields.every(
(field) =>
Boolean(field) &&
typeof field.id === 'string' &&
Boolean(field.id.trim()) &&
typeof field.title === 'string' &&
(typeof field.value === 'string' ||
(typeof field.value === 'number' && Number.isFinite(field.value)) ||
typeof field.value === 'boolean'),
) &&
value.references.every(
(reference) =>
Boolean(reference) &&
typeof reference.id === 'string' &&
Boolean(reference.id.trim()) &&
typeof reference.title === 'string' &&
(reference.label === undefined ||
typeof reference.label === 'string') &&
(reference.refType === 'project-resource' ||
reference.refType === 'asset') &&
typeof reference.refId === 'string' &&
Boolean(reference.refId.trim()),
),
);
return isNormalizedCanvasGenerationInputsStructure(value);
}
type NormalizedCanvasGenerationInputs = CanvasGenerationInputs & {
@@ -1566,11 +1545,17 @@ export function canOpenRedrawPanel(
layer: CanvasLayer,
availableLayers: CanvasLayer[] = [],
) {
if (layer.sourceType === 'uploaded') {
if (
layer.sourceType === 'uploaded' ||
layer.taskId?.startsWith('pixel-art-snap-')
) {
return false;
}
const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs);
if (decodedInputs.ok) {
if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
return false;
}
if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
return true;
}
@@ -1741,11 +1726,16 @@ export function buildBackgroundMusicGenerationInputs(
return {
version: 2,
action: 'audio.background-music.generate',
fields: createGenerationInputField(
'gpt_description_prompt',
gptDescriptionPrompt,
'prompt',
),
fields:
gptDescriptionPrompt === ''
? []
: [
{
id: 'prompt',
title: 'gpt_description_prompt',
value: gptDescriptionPrompt,
},
],
references: [],
};
}
@@ -224,6 +224,29 @@ describe('ImageCanvasMetadataModalView', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders true and false generation input values as visible text', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({
generationInputs: {
version: 2,
action: 'video.generate',
fields: [
{ id: 'webSearchEnabled', title: '联网搜索', value: true },
{ id: 'soundEnabled', title: '启用声音', value: false },
],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '图片信息' });
expect(within(dialog).getByText('true')).toBeTruthy();
expect(within(dialog).getByText('false')).toBeTruthy();
});
it('renders audio duration from generation inputs instead of layer metadata', () => {
render(
<ImageCanvasMetadataModalView
@@ -2,6 +2,7 @@ import { UnifiedModal } from '../common/UnifiedModal';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
import {
formatGenerationInputValue,
formatLayerImageType,
getEditorLayerModelDisplayName,
isEditorUserVisibleGenerationInputField,
@@ -63,7 +64,7 @@ export function ImageCanvasMetadataModalView({
<span className="image-canvas-editor__metadata-input-title">
{field.title}
</span>
<span>{field.value}</span>
<span>{formatGenerationInputValue(field.value)}</span>
</div>
))}
{generationInputReferences.length ? (
@@ -372,6 +372,26 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy();
});
it.each([
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const)('never exposes redraw for deterministic action %s', (action) => {
renderSelectedToolbar({
selectedLayer: createLayer({
generationInputs: {
version: 2,
action,
fields: [],
references: [],
},
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
});
it('removes raster edit actions for video and character animation layers', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
@@ -62,7 +62,27 @@ describe('perfectPixelOperationStore', () => {
});
it('round-trips an operation for the same owner and project', () => {
const operation = buildOperation('dialog-1');
const operation = buildOperation('dialog-1', {
request: {
...buildOperation('dialog-1').request,
generationInputs: {
version: 2,
action: 'image.perfect-pixel',
fields: [
{ id: 'scale', title: '数字兼容', value: 2 },
{ id: 'enabled', title: '布尔兼容', value: false },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
},
});
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
@@ -291,6 +291,19 @@ function createHydratedPerfectPixelDialog({
sourceImageSrc: 'generated-images/editor/source.png',
projectId,
sourceResourceId: 'resource-source',
generationInputs: {
version: 2,
action: 'image.perfect-pixel',
fields: [],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId: operationId,
@@ -2401,6 +2414,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
height: 280,
sourceType: 'generated',
sourceResourceId: 'resource-source',
generationInputs: {
version: 2,
action: 'image.crop-expand',
fields: [],
references: [
expect.objectContaining({
id: 'source',
refType: 'project-resource',
refId: 'resource-source',
}),
],
},
}),
);
await waitFor(() => {
@@ -2572,7 +2597,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: undefined,
targetLayerId: 'layer-source',
assetKind: 'character',
generationInputs: null,
generationInputs: {
version: 2,
action: 'image.remove-background',
fields: [],
references: [
expect.objectContaining({
id: 'source',
refType: 'project-resource',
refId: 'resource-source',
}),
],
},
assetFolderId: undefined,
assetLabel: '源图 去背景',
sourceResourceId: 'resource-source',
@@ -2619,6 +2655,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: 'project-1',
targetLayerId: 'layer-source',
assetLabel: '源图 去背景',
generationInputs: {
version: 2,
action: 'image.remove-background',
fields: [],
references: [
expect.objectContaining({
id: 'source',
refType: 'project-resource',
refId: 'resource-source',
}),
],
},
canvasCompletion: expect.objectContaining({
dialogId: 'generation-dialog-1',
title: '源图 去背景',
@@ -2751,7 +2799,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: sourceLayer.generationInputs,
generationInputs: {
version: 2,
action: 'image.perfect-pixel',
fields: [],
references: [
expect.objectContaining({
id: 'source',
refType: 'project-resource',
refId: 'resource-source',
}),
],
},
assetLabel: '源图 · 完美像素',
canvasCompletion: expect.objectContaining({
dialogId: flushedOperation!.operationId,
@@ -87,6 +87,7 @@ import {
} from './ImageCanvasGenerationDialogModel';
import {
appendLimitedImageReferences,
buildDeterministicGenerationInputs,
calculateCharacterAnimationPrice,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_DURATION_OPTIONS,
@@ -2177,7 +2178,10 @@ export function useImageCanvasGenerationWorkflow({
taskId: cropExpandSourceLayer.taskId,
sourceResourceId: cropExpandSourceLayer.resourceId,
assetKind: cropExpandSourceLayer.assetKind,
generationInputs: null,
generationInputs: buildDeterministicGenerationInputs(
'image.crop-expand',
cropExpandSourceLayer,
),
},
);
cropExpandResourceId = cropExpandResource.resourceId;
@@ -2212,7 +2216,10 @@ export function useImageCanvasGenerationWorkflow({
assetObjectId: cropExpandAssetObjectId,
sourceAssetId: null,
assetKind: cropExpandAssetKind,
generationInputs: null,
generationInputs: buildDeterministicGenerationInputs(
'image.crop-expand',
cropExpandSourceLayer,
),
};
captureCanvasHistory({ type: 'expand-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
@@ -2289,7 +2296,10 @@ export function useImageCanvasGenerationWorkflow({
projectId,
targetLayerId: sourceLayer.id,
assetKind: sourceLayer.assetKind,
generationInputs: null,
generationInputs: buildDeterministicGenerationInputs(
'image.remove-background',
sourceLayer,
),
assetFolderId,
assetLabel,
sourceResourceId: sourceLayer.resourceId,
@@ -2570,18 +2580,10 @@ export function useImageCanvasGenerationWorkflow({
...(sourceLayer.assetKind
? { assetKind: sourceLayer.assetKind }
: {}),
...(sourceLayer.generationInputs
? {
generationInputs: {
fields: sourceLayer.generationInputs.fields.map((field) => ({
...field,
})),
references: sourceLayer.generationInputs.references.map(
(reference) => ({ ...reference }),
),
},
}
: {}),
generationInputs: buildDeterministicGenerationInputs(
'image.perfect-pixel',
sourceLayer,
),
...(assetFolderId ? { assetFolderId } : {}),
assetLabel,
canvasCompletion: {
@@ -138,7 +138,11 @@ export type EditorAssetGenerationInputs = {
| 'audio.background-music.generate'
| 'character-animation.generate'
| 'image.edit'
| 'ui-design.extract-assets';
| 'ui-design.extract-assets'
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand';
fields: EditorAssetGenerationInputField[];
references: EditorAssetGenerationInputReference[];
[key: string]: unknown;