修复画布素材标签跨媒体覆盖

后端按图片、动作、视频和音频媒体族校验图层素材类型覆盖。

不兼容覆盖清空并回退资源默认类型,保存请求继续成功。

动作资源回退时清理布局媒体副本并补充结构化画布回归测试。

同步更新后端数据契约中的兼容与降级规则。
This commit is contained in:
2026-08-05 20:32:47 +08:00
parent ab5f2cbba1
commit ca19a3cca3
2 changed files with 160 additions and 6 deletions
@@ -637,7 +637,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorCanvasLayer`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布结构化图层表,一行保存一个 layer 的 canvas / project / owner 归属、几何、层级顺序、分组、hidden / locked / flip 状态、`resource_id` 与可空 `asset_kind_override``asset_kind_override` 是布局实例的素材类型覆盖,资源默认值仍由 `editor_project_resource.asset_kind` 持有,有效类型统一按 `override ?? resource default` 计算;修改图层标签不得创建资源或替换 `resource_id`。V1 的 `item_json` 只保留未结构化扩展字段,单行最大 512 KiB;快照以 typed 列重组,不得把完整图层 JSON 当作平行真相。历史 layout 的 `sourceResourceId == resourceId` 属于无意义自引用,迁移时按资源表真相剥离;其他资源字段冲突继续 fail-closed。唯一存量缺资源例外是已缺资源行、但帧与预览均为稳定站内对象路径的 `local-* + generated + image-sequence` 历史角色动作图层:迁移保留其有界媒体扩展并纳入 canonical hashactive 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。
- 说明:图片画布结构化图层表,一行保存一个 layer 的 canvas / project / owner 归属、几何、层级顺序、分组、hidden / locked / flip 状态、`resource_id` 与可空 `asset_kind_override``asset_kind_override` 是布局实例的素材类型覆盖,资源默认值仍由 `editor_project_resource.asset_kind` 持有,有效类型统一按 `override ?? resource default` 计算;修改图层标签不得创建资源或替换 `resource_id`覆盖值只能在资源默认类型的同一媒体族内变化:`character-animation` 为动作族,`video` 为视频族,`audio/sound-effect/background-music` 为音频族,其余值和空默认值为图片族。结构化保存必须对每个已解析资源完整校验该规则;跨族覆盖不拒绝整次保存,而是清除 `asset_kind_override` 并回退资源默认类型。若正式动作资源因该回退路径携带了 layout 媒体副本,同时丢弃这些副本并继续以资源行序列字段为权威;其他动作图层复制资源结果字段仍然失败关闭。V1 的 `item_json` 只保留未结构化扩展字段,单行最大 512 KiB;快照以 typed 列重组,不得把完整图层 JSON 当作平行真相。历史 layout 的 `sourceResourceId == resourceId` 属于无意义自引用,迁移时按资源表真相剥离;其他资源字段冲突继续 fail-closed。唯一存量缺资源例外是已缺资源行、但帧与预览均为稳定站内对象路径的 `local-* + generated + image-sequence` 历史角色动作图层:迁移保留其有界媒体扩展并纳入 canonical hashactive 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。
- 索引:按 canvas 读取完整结构化快照,按 project 做级联清理;owner 保留在行内用于归属校验。同一 canvas 的 layer id 必须稳定且唯一。
### `editor_canvas_generation_dialog`
@@ -6489,6 +6489,8 @@ fn normalize_structured_canvas_layer_against_resource(
layer.resource_id
));
}
let asset_kind_override_fell_back =
normalize_canvas_asset_kind_override_against_resource(layer, resource);
if resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) {
let rejected = [
"generationInputs",
@@ -6504,11 +6506,17 @@ fn normalize_structured_canvas_layer_against_resource(
.filter(|field| item.contains_key(*field))
.collect::<Vec<_>>();
if !rejected.is_empty() {
return Err(format!(
"图片画布角色动作图层 {} 不能复制资源结果字段:{}",
layer.layer_id,
rejected.join(", ")
));
if asset_kind_override_fell_back {
for field in rejected {
item.remove(field);
}
} else {
return Err(format!(
"图片画布角色动作图层 {} 不能复制资源结果字段:{}",
layer.layer_id,
rejected.join(", ")
));
}
}
}
@@ -6598,6 +6606,39 @@ fn normalize_structured_canvas_layer_against_resource(
Ok(Some((legacy_asset_kind, layout_generation_inputs)))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditorCanvasMediaFamily {
Image,
CharacterAnimation,
Video,
Audio,
}
fn editor_canvas_media_family(asset_kind: Option<&str>) -> EditorCanvasMediaFamily {
match asset_kind {
Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) => EditorCanvasMediaFamily::CharacterAnimation,
Some("video") => EditorCanvasMediaFamily::Video,
Some("audio" | "sound-effect" | "background-music") => EditorCanvasMediaFamily::Audio,
_ => EditorCanvasMediaFamily::Image,
}
}
fn normalize_canvas_asset_kind_override_against_resource(
layer: &mut StructuredCanvasLayerDraft,
resource: &EditorProjectResource,
) -> bool {
let Some(asset_kind_override) = layer.asset_kind_override.as_deref() else {
return false;
};
if editor_canvas_media_family(Some(asset_kind_override))
== editor_canvas_media_family(resource.asset_kind.as_deref())
{
return false;
}
layer.asset_kind_override = None;
true
}
fn merge_legacy_asset_kind_into_layer_override(
layer: &mut StructuredCanvasLayerDraft,
legacy_asset_kind: Option<String>,
@@ -13259,6 +13300,36 @@ mod tests {
);
}
fn canvas_override_test_resource(asset_kind: Option<&str>) -> EditorProjectResource {
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
let is_character_animation = asset_kind == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND);
EditorProjectResource {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated/resource-1.png".to_string(),
object_key: Some("generated/resource-1.png".to_string()),
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
created_at: now,
updated_at: now,
asset_kind: asset_kind.map(str::to_string),
generation_inputs_json: None,
public_showcase_enabled: true,
image_sequence_frames_json: is_character_animation
.then(|| migration_frames("resource", false).to_string()),
image_sequence_duration_ms: is_character_animation.then_some(1_000),
}
}
#[test]
fn structured_canvas_asset_kind_override_is_optional_and_fail_closed() {
let legacy = r#"[{"layerId":"layer-1","resourceId":"resource-1"}]"#;
@@ -13331,6 +13402,89 @@ mod tests {
assert!(!layout.layers[0].item_json.contains("assetKindOverride"));
}
#[test]
fn structured_canvas_incompatible_asset_kind_override_falls_back_to_resource_default() {
for (resource_asset_kind, asset_kind_override, expected_override) in [
(Some("image"), "character-animation", None),
(Some("image"), "video", None),
(Some("image"), "sound-effect", None),
(Some("video"), "character", None),
(Some("audio"), "background-music", Some("background-music")),
(
Some("sound-effect"),
"background-music",
Some("background-music"),
),
(Some("character-animation"), "character", None),
(
Some("character-animation"),
"character-animation",
Some("character-animation"),
),
(Some("image"), "icon", Some("icon")),
(None, "character", Some("character")),
] {
let resource = canvas_override_test_resource(resource_asset_kind);
let mut layout = parse_structured_canvas_layout(
json!([{
"layerId": "layer-1",
"resourceId": "resource-1",
"assetKindOverride": asset_kind_override,
}])
.to_string()
.as_str(),
)
.expect("override layout should parse");
normalize_structured_canvas_layer_against_resource(
&mut layout.layers[0],
Some(&resource),
)
.expect("incompatible override should fall back without rejecting the save");
assert_eq!(
layout.layers[0].asset_kind_override.as_deref(),
expected_override,
"resource={resource_asset_kind:?}, override={asset_kind_override}",
);
}
}
#[test]
fn structured_canvas_action_override_fallback_discards_layout_media_copies() {
let resource = canvas_override_test_resource(Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND));
let mut layout = parse_structured_canvas_layout(
json!([{
"layerId": "layer-1",
"resourceId": "resource-1",
"assetKindOverride": "character",
"mediaType": "image-sequence",
"thumbnailSrc": "/generated/resource-1.png",
"imageSequenceFrames": migration_frames("resource", false),
"imageSequenceDurationMs": 1_000,
}])
.to_string()
.as_str(),
)
.expect("action override layout should parse");
normalize_structured_canvas_layer_against_resource(&mut layout.layers[0], Some(&resource))
.expect("fallback should keep the resource-authoritative save valid");
assert_eq!(layout.layers[0].asset_kind_override, None);
for field in [
"mediaType",
"thumbnailSrc",
"imageSequenceFrames",
"imageSequenceDurationMs",
] {
assert!(
!layout.layers[0].item_json.contains(field),
"fallback must discard {field} from layout copies"
);
}
}
#[test]
fn structured_canvas_legacy_asset_kinds_migrate_deterministically() {
let legacy = r#"[