修复画布素材标签跨媒体覆盖
后端按图片、动作、视频和音频媒体族校验图层素材类型覆盖。 不兼容覆盖清空并回退资源默认类型,保存请求继续成功。 动作资源回退时清理布局媒体副本并补充结构化画布回归测试。 同步更新后端数据契约中的兼容与降级规则。
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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#"[
|
||||
|
||||
Reference in New Issue
Block a user