From ca19a3cca33fb71bced257a89bee5672d5b0dcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 20:32:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E6=A0=87=E7=AD=BE=E8=B7=A8=E5=AA=92=E4=BD=93=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端按图片、动作、视频和音频媒体族校验图层素材类型覆盖。 不兼容覆盖清空并回退资源默认类型,保存请求继续成功。 动作资源回退时清理布局媒体副本并补充结构化画布回归测试。 同步更新后端数据契约中的兼容与降级规则。 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../src/editor_project_storage.rs | 164 +++++++++++++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 594852369..b0d464b7d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 hash;active 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 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 hash;active 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。 - 索引:按 canvas 读取完整结构化快照,按 project 做级联清理;owner 保留在行内用于归属校验。同一 canvas 的 layer id 必须稳定且唯一。 ### `editor_canvas_generation_dialog` diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 833488e29..dcc4571ca 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -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::>(); 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, @@ -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#"[