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

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

统一 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
@@ -6747,3 +6747,10 @@
- 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。
- 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。
- 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能。
## 2026-08-07 确定性派生配方与改造 capability 分离
- 决策:`generationInputs` 是持久化配方 / 来源账本,不直接代表“允许改造”。完美像素、裁扩、所有手动与自动图集切片、手动去背景分别写 `image.perfect-pixel``image.crop-expand``spritesheet.split``image.remove-background`,固定 `fields: []`;有正式来源行时只保存服务端权威 `references[id="source"]`,没有正式行时为空。这四个 action 不进入改造 allowlist,历史 `pixel-art-snap-*` 同样失败关闭;整张生成图集继续保留生成 action,自动抠图仍是生成流程内部后处理。
- V2 水合统一按 `version/action/fields[].id/references[].id` 严格识别,保留有限数字、布尔值与无标签引用;出现 V2 标记但结构无效时不得降级 legacy。完美像素账本复用相同 V2 白名单,外层仍为 version 1,并继续原形接受旧 legacy 请求以维持 exact retry fingerprint。
- 安全边界:站内已迁移队列只保留客户端 references 的安全槽位 `id`,真实 `title/label/refType/refId` 全部按 owner-scoped 记录重建;External API 和直接不可信写入仍删除整段 references。队列幂等比较忽略该冗余展示槽位,但继续严格比较实际媒体来源与其它参数。
- 不改 SpacetimeDB schema、路由或 External OpenAPI;不回填历史记录。
File diff suppressed because one or more lines are too long
@@ -114,7 +114,8 @@
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version``action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy
- 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel``image.remove-background``image.crop-expand``spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -205,14 +205,6 @@ fn ensure_editor_generation_job_matches_request(
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
@@ -243,22 +235,21 @@ fn editor_generation_request_payloads_match(
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
let (Ok(mut existing), Ok(mut requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
{
// 这些站内 job 的真实来源始终由 sourceImageSrc / referenceImageSrcs 等请求字段表达,
// generationInputs.references 只携带随后由 owner-scoped 记录重建的展示槽位。滚动部署
// 期间旧任务可能没有 references、新任务可能只含安全 id;比较幂等请求时两边都移除
// 该冗余字段,实际媒体来源或其它参数的任何变化仍会冲突。视频等未迁移 job 保持严格比较。
if !job_kind_migrated_away_from_client_generation_references(job_kind) {
return false;
}
strip_untrusted_generation_input_references_from_payload(&mut existing);
strip_untrusted_generation_input_references_from_payload(&mut requested);
existing == requested
}
@@ -613,6 +604,45 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_migrated_job_accepts_new_safe_reference_slots() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "提示词", "value": "same"}]
}
}))
.expect("existing payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "提示词", "value": "same"}],
"references": [{"id": "reference"}]
}
}))
.expect("requested payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
}
#[test]
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
let mut job = queue_job_fixture("queued", None);
File diff suppressed because it is too large Load Diff
@@ -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;