修复画布生成配方恢复与快照失真
保持背景音乐规范提示词与生成输入快照一致。 统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。 为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。 由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。 补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user