From 7d46bd81852b774993085559fb483dad382b5691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 4 Aug 2026 10:11:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=A7=92=E8=89=B2=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=AD=A3=E5=BC=8F=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为项目资源、账号素材和精选快照新增正式图片序列帧与毫秒时长字段。 统一 Rust DTO、SpacetimeDB 表、迁移默认值、typed client mapper 与生成绑定。 数组顺序作为唯一帧序,不持久化 frameIndex、frameCount、fps 或通用媒体时长。 --- .../crates/shared-contracts/src/admin.rs | 16 + .../crates/shared-contracts/src/assets.rs | 25 +- .../src/active/mapper/editor_project.rs | 50 +- .../src/mapper/editor_project.rs | 40 ++ .../admin_editor_asset_snapshot_type.rs | 2 + .../editor_asset_create_input_type.rs | 2 + .../editor_asset_snapshot_type.rs | 2 + .../src/module_bindings/editor_asset_type.rs | 12 + ...itor_project_resource_create_input_type.rs | 2 + .../editor_project_resource_snapshot_type.rs | 2 + .../editor_project_resource_type.rs | 13 + .../editor_showcase_asset_snapshot_type.rs | 2 + .../editor_showcase_asset_type.rs | 13 + .../src/editor_project_storage.rs | 675 ++++++++++++++++-- .../crates/spacetime-module/src/migration.rs | 16 + 15 files changed, 816 insertions(+), 56 deletions(-) diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index b9cb6a477..fff4c8f86 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -243,6 +243,8 @@ pub struct AdminEditorAssetPayload { pub task_generator: String, pub task_cost_mud_points: u64, pub children: Vec, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -300,6 +302,8 @@ pub struct AdminEditorShowcaseAssetPayload { pub rejected_at: Option, pub updated_at: String, pub showcase_category: Option, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1116,6 +1120,11 @@ mod tests { rejected_at: None, updated_at: "2026-07-04T10:00:00.000Z".to_string(), showcase_category: None, + image_sequence_frames: Some(json!([ + { "imageSrc": "/generated/action/frame-01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/action/frame-02.png", "width": 192, "height": 256 } + ])), + image_sequence_duration_ms: Some(5_000), }; let value = serde_json::to_value(payload).expect("payload should serialize"); @@ -1126,8 +1135,15 @@ mod tests { value["thumbnailSrc"], json!("/generated-character-drafts/editor/spec-thumb.png") ); + assert_eq!( + value["imageSequenceFrames"].as_array().map(Vec::len), + Some(2) + ); + assert_eq!(value["imageSequenceDurationMs"], json!(5_000)); assert!(value.get("author_display_name").is_none()); assert!(value.get("author_public_user_code").is_none()); assert!(value.get("thumbnail_src").is_none()); + assert!(value.get("image_sequence_frames").is_none()); + assert!(value.get("image_sequence_duration_ms").is_none()); } } diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 27ca2036d..dcdef9af5 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -341,7 +341,6 @@ pub struct EditorCharacterAnimationGenerateRequest { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorCharacterAnimationFramePayload { - pub frame_index: u32, pub image_src: String, pub width: u32, pub height: u32, @@ -365,6 +364,10 @@ pub struct EditorCharacterAnimationGenerateResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub queue_state: Option, } @@ -1142,7 +1145,6 @@ mod tests { prompt: "生成游戏角色动画".to_string(), preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(), frames: vec![EditorCharacterAnimationFramePayload { - frame_index: 1, image_src: "/generated-animations/editor/layer/frame01.png".to_string(), width: 768, height: 1024, @@ -1154,10 +1156,27 @@ mod tests { fps: 8, price_mud_points: 40, project: None, + resource: Some(json!({ + "resourceId": "editor-resource-action-1", + "assetKind": "character-animation", + })), + asset: Some(json!({ + "assetId": "editor-asset-action-1", + "assetKind": "character-animation", + "frameCount": 2, + })), queue_state: None, }) .expect("response should serialize"); + assert_eq!(payload["asset"]["assetId"], json!("editor-asset-action-1")); + assert_eq!( + payload["resource"]["resourceId"], + json!("editor-resource-action-1") + ); + assert_eq!(payload["asset"]["assetKind"], json!("character-animation")); + assert_eq!(payload["asset"]["frameCount"], json!(2)); + assert_eq!( payload["previewVideoPath"], json!("/generated-character-drafts/editor/layer/preview.mp4") @@ -1166,6 +1185,7 @@ mod tests { payload["frames"][0]["imageSrc"], json!("/generated-animations/editor/layer/frame01.png") ); + assert!(payload["frames"][0].get("frameIndex").is_none()); assert_eq!(payload["fps"], json!(8)); } @@ -1439,6 +1459,7 @@ mod tests { ); assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1")); assert_eq!(response_payload["audioKind"], json!("sound-effect")); + assert!(response_payload.get("durationSeconds").is_none()); assert_eq!( response_payload["asset"]["assetKind"], json!("sound-effect") diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index fa3b03ec2..640a5946a 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -73,6 +73,8 @@ pub struct EditorProjectResourceRecord { pub public_showcase_enabled: bool, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -114,6 +116,8 @@ pub struct EditorAssetRecord { pub showcase_like_count: Option, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -141,6 +145,8 @@ pub struct AdminEditorAssetRecord { pub generation_cost_mud_points: u64, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -186,6 +192,8 @@ pub struct EditorShowcaseAssetRecord { pub rejected_at: Option, pub updated_at: String, pub showcase_category: Option, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -293,6 +301,8 @@ pub struct EditorProjectResourceCreateRecordInput { pub asset_kind: Option, pub generation_inputs_json: Option, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -369,6 +379,8 @@ pub struct EditorAssetCreateRecordInput { pub generation_cost_mud_points: u64, pub group_task_id: Option, pub group_task_expected_asset_count: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -608,6 +620,8 @@ impl From asset_kind: input.asset_kind, generation_inputs_json: input.generation_inputs_json, updated_at_micros: input.updated_at_micros, + image_sequence_frames_json: input.image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, } } } @@ -717,6 +731,8 @@ impl From for crate::module_bindings::EditorAssetC generation_cost_mud_points: input.generation_cost_mud_points, group_task_id: input.group_task_id, group_task_expected_asset_count: input.group_task_expected_asset_count, + image_sequence_frames_json: input.image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, } } } @@ -1244,10 +1260,14 @@ fn map_editor_project_snapshot( fn map_editor_project_resource_snapshot( snapshot: EditorProjectResourceSnapshot, ) -> Result { - let generation_inputs = parse_optional_generation_inputs_json( + let generation_inputs = parse_optional_json( snapshot.generation_inputs_json.as_deref(), "图片画布资源生成输入 JSON", )?; + let image_sequence_frames = parse_optional_json( + snapshot.image_sequence_frames_json.as_deref(), + "图片画布资源序列帧 JSON", + )?; Ok(EditorProjectResourceRecord { resource_id: snapshot.resource_id, project_id: snapshot.project_id, @@ -1269,6 +1289,8 @@ fn map_editor_project_resource_snapshot( public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } @@ -1306,10 +1328,14 @@ fn map_editor_asset_folder_snapshot( fn map_editor_asset_snapshot( snapshot: EditorAssetSnapshot, ) -> Result { - let generation_inputs = parse_optional_generation_inputs_json( + let generation_inputs = parse_optional_json( snapshot.generation_inputs_json.as_deref(), "图片画布素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_json( + snapshot.image_sequence_frames_json.as_deref(), + "图片画布素材序列帧 JSON", + )?; Ok(EditorAssetRecord { asset_id: snapshot.asset_id, folder_id: snapshot.folder_id, @@ -1337,16 +1363,22 @@ fn map_editor_asset_snapshot( showcase_like_count: snapshot.showcase_like_count, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } fn map_admin_editor_asset_snapshot( snapshot: AdminEditorAssetSnapshot, ) -> Result { - let generation_inputs = parse_optional_generation_inputs_json( + let generation_inputs = parse_optional_json( snapshot.generation_inputs_json.as_deref(), "后台图片画布素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_json( + snapshot.image_sequence_frames_json.as_deref(), + "后台图片画布素材序列帧 JSON", + )?; Ok(AdminEditorAssetRecord { asset_id: snapshot.asset_id, owner_user_id: snapshot.owner_user_id, @@ -1371,16 +1403,22 @@ fn map_admin_editor_asset_snapshot( generation_cost_mud_points: snapshot.generation_cost_mud_points, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } fn map_editor_showcase_asset_snapshot( snapshot: EditorShowcaseAssetSnapshot, ) -> Result { - let generation_inputs = parse_optional_generation_inputs_json( + let generation_inputs = parse_optional_json( snapshot.generation_inputs_json.as_deref(), "陶泥儿精选素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_json( + snapshot.image_sequence_frames_json.as_deref(), + "陶泥儿精选素材序列帧 JSON", + )?; Ok(EditorShowcaseAssetRecord { showcase_id: snapshot.showcase_id, asset_id: snapshot.asset_id, @@ -1419,6 +1457,8 @@ fn map_editor_showcase_asset_snapshot( rejected_at: snapshot.rejected_at_micros.map(format_timestamp_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), showcase_category: snapshot.showcase_category, + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } @@ -1468,7 +1508,7 @@ fn map_editor_generation_pricing_config_snapshot( } } -fn parse_optional_generation_inputs_json( +fn parse_optional_json( raw_json: Option<&str>, label: &str, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index fa3b03ec2..a54b95794 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -73,6 +73,8 @@ pub struct EditorProjectResourceRecord { pub public_showcase_enabled: bool, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -114,6 +116,8 @@ pub struct EditorAssetRecord { pub showcase_like_count: Option, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -141,6 +145,8 @@ pub struct AdminEditorAssetRecord { pub generation_cost_mud_points: u64, pub created_at: String, pub updated_at: String, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -186,6 +192,8 @@ pub struct EditorShowcaseAssetRecord { pub rejected_at: Option, pub updated_at: String, pub showcase_category: Option, + pub image_sequence_frames: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -293,6 +301,8 @@ pub struct EditorProjectResourceCreateRecordInput { pub asset_kind: Option, pub generation_inputs_json: Option, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -369,6 +379,8 @@ pub struct EditorAssetCreateRecordInput { pub generation_cost_mud_points: u64, pub group_task_id: Option, pub group_task_expected_asset_count: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -608,6 +620,8 @@ impl From asset_kind: input.asset_kind, generation_inputs_json: input.generation_inputs_json, updated_at_micros: input.updated_at_micros, + image_sequence_frames_json: input.image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, } } } @@ -717,6 +731,8 @@ impl From for crate::module_bindings::EditorAssetC generation_cost_mud_points: input.generation_cost_mud_points, group_task_id: input.group_task_id, group_task_expected_asset_count: input.group_task_expected_asset_count, + image_sequence_frames_json: input.image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, } } } @@ -1248,6 +1264,10 @@ fn map_editor_project_resource_snapshot( snapshot.generation_inputs_json.as_deref(), "图片画布资源生成输入 JSON", )?; + let image_sequence_frames = parse_optional_generation_inputs_json( + snapshot.image_sequence_frames_json.as_deref(), + "图片画布资源序列帧 JSON", + )?; Ok(EditorProjectResourceRecord { resource_id: snapshot.resource_id, project_id: snapshot.project_id, @@ -1269,6 +1289,8 @@ fn map_editor_project_resource_snapshot( public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } @@ -1310,6 +1332,10 @@ fn map_editor_asset_snapshot( snapshot.generation_inputs_json.as_deref(), "图片画布素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_generation_inputs_json( + snapshot.image_sequence_frames_json.as_deref(), + "图片画布素材序列帧 JSON", + )?; Ok(EditorAssetRecord { asset_id: snapshot.asset_id, folder_id: snapshot.folder_id, @@ -1337,6 +1363,8 @@ fn map_editor_asset_snapshot( showcase_like_count: snapshot.showcase_like_count, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } @@ -1347,6 +1375,10 @@ fn map_admin_editor_asset_snapshot( snapshot.generation_inputs_json.as_deref(), "后台图片画布素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_generation_inputs_json( + snapshot.image_sequence_frames_json.as_deref(), + "后台图片画布素材序列帧 JSON", + )?; Ok(AdminEditorAssetRecord { asset_id: snapshot.asset_id, owner_user_id: snapshot.owner_user_id, @@ -1371,6 +1403,8 @@ fn map_admin_editor_asset_snapshot( generation_cost_mud_points: snapshot.generation_cost_mud_points, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } @@ -1381,6 +1415,10 @@ fn map_editor_showcase_asset_snapshot( snapshot.generation_inputs_json.as_deref(), "陶泥儿精选素材生成输入 JSON", )?; + let image_sequence_frames = parse_optional_generation_inputs_json( + snapshot.image_sequence_frames_json.as_deref(), + "陶泥儿精选素材序列帧 JSON", + )?; Ok(EditorShowcaseAssetRecord { showcase_id: snapshot.showcase_id, asset_id: snapshot.asset_id, @@ -1419,6 +1457,8 @@ fn map_editor_showcase_asset_snapshot( rejected_at: snapshot.rejected_at_micros.map(format_timestamp_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), showcase_category: snapshot.showcase_category, + image_sequence_frames, + image_sequence_duration_ms: snapshot.image_sequence_duration_ms, }) } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs index 1631748e8..be01aa17c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs @@ -30,6 +30,8 @@ pub struct AdminEditorAssetSnapshot { pub created_at_micros: i64, pub updated_at_micros: i64, pub group_task_id: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for AdminEditorAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs index 1984b7e75..8af853af8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs @@ -30,6 +30,8 @@ pub struct EditorAssetCreateInput { pub generation_cost_mud_points: u64, pub group_task_id: Option, pub group_task_expected_asset_count: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorAssetCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs index c39d1d015..9b8be8ba2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs @@ -34,6 +34,8 @@ pub struct EditorAssetSnapshot { pub showcase_display_enabled: Option, pub showcase_like_count: Option, pub group_task_id: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index 34f4046be..4ad962299 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -31,6 +31,8 @@ pub struct EditorAsset { pub generation_cost_mud_points: u64, pub group_task_id: Option, pub group_task_expected_asset_count: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorAsset { @@ -65,6 +67,8 @@ pub struct EditorAssetCols { pub generation_cost_mud_points: __sdk::__query_builder::Col, pub group_task_id: __sdk::__query_builder::Col>, pub group_task_expected_asset_count: __sdk::__query_builder::Col>, + pub image_sequence_frames_json: __sdk::__query_builder::Col>, + pub image_sequence_duration_ms: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorAsset { @@ -104,6 +108,14 @@ impl __sdk::__query_builder::HasCols for EditorAsset { table_name, "group_task_expected_asset_count", ), + image_sequence_frames_json: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_frames_json", + ), + image_sequence_duration_ms: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_duration_ms", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs index cac8c3376..7330b69e0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs @@ -25,6 +25,8 @@ pub struct EditorProjectResourceCreateInput { pub asset_kind: Option, pub generation_inputs_json: Option, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorProjectResourceCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index b74e44b80..6b41ba01e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -27,6 +27,8 @@ pub struct EditorProjectResourceSnapshot { pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorProjectResourceSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs index 4ef4a0a43..e70213fc7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -27,6 +27,8 @@ pub struct EditorProjectResource { pub asset_kind: Option, pub generation_inputs_json: Option, pub public_showcase_enabled: bool, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorProjectResource { @@ -57,6 +59,9 @@ pub struct EditorProjectResourceCols { pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, pub public_showcase_enabled: __sdk::__query_builder::Col, + pub image_sequence_frames_json: + __sdk::__query_builder::Col>, + pub image_sequence_duration_ms: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorProjectResource { @@ -89,6 +94,14 @@ impl __sdk::__query_builder::HasCols for EditorProjectResource { table_name, "public_showcase_enabled", ), + image_sequence_frames_json: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_frames_json", + ), + image_sequence_duration_ms: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_duration_ms", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs index c9df1b539..9e7121394 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs @@ -42,6 +42,8 @@ pub struct EditorShowcaseAssetSnapshot { pub rejected_at_micros: Option, pub updated_at_micros: i64, pub showcase_category: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorShowcaseAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_type.rs index a4ffb617b..8e11512f2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_type.rs @@ -42,6 +42,8 @@ pub struct EditorShowcaseAsset { pub rejected_at: Option<__sdk::Timestamp>, pub updated_at: __sdk::Timestamp, pub showcase_category: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } impl __sdk::InModule for EditorShowcaseAsset { @@ -88,6 +90,9 @@ pub struct EditorShowcaseAssetCols { pub rejected_at: __sdk::__query_builder::Col>, pub updated_at: __sdk::__query_builder::Col, pub showcase_category: __sdk::__query_builder::Col>, + pub image_sequence_frames_json: + __sdk::__query_builder::Col>, + pub image_sequence_duration_ms: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorShowcaseAsset { @@ -144,6 +149,14 @@ impl __sdk::__query_builder::HasCols for EditorShowcaseAsset { rejected_at: __sdk::__query_builder::Col::new(table_name, "rejected_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), showcase_category: __sdk::__query_builder::Col::new(table_name, "showcase_category"), + image_sequence_frames_json: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_frames_json", + ), + image_sequence_duration_ms: __sdk::__query_builder::Col::new( + table_name, + "image_sequence_duration_ms", + ), } } } 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 5bebf74c8..e565fab21 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -233,6 +233,11 @@ pub struct EditorProjectResource { generation_inputs_json: Option, #[default(true)] public_showcase_enabled: bool, + // 角色动作运行结果独立于视频 / 音频生成参数,不写入 generation_inputs_json。 + #[default(None::)] + image_sequence_frames_json: Option, + #[default(None::)] + image_sequence_duration_ms: Option, } #[spacetimedb::table( @@ -289,6 +294,11 @@ pub struct EditorAsset { group_task_id: Option, #[default(None::)] group_task_expected_asset_count: Option, + // 角色动作运行结果独立于视频 / 音频生成参数,不写入 generation_inputs_json。 + #[default(None::)] + image_sequence_frames_json: Option, + #[default(None::)] + image_sequence_duration_ms: Option, } #[spacetimedb::table(accessor = editor_asset_group_source_provenance)] @@ -356,6 +366,11 @@ pub struct EditorShowcaseAsset { updated_at: Timestamp, #[default(None::)] showcase_category: Option, + // 精选公开记录是独立冻结快照,不能依赖后续仍存在的账号素材行恢复角色动作。 + #[default(None::)] + image_sequence_frames_json: Option, + #[default(None::)] + image_sequence_duration_ms: Option, } #[spacetimedb::table( @@ -577,6 +592,8 @@ pub struct EditorProjectResourceCreateInput { pub asset_kind: Option, pub generation_inputs_json: Option, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -625,6 +642,8 @@ pub struct EditorProjectResourceSnapshot { pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -667,6 +686,8 @@ pub struct EditorAssetSnapshot { pub showcase_display_enabled: Option, pub showcase_like_count: Option, pub group_task_id: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -704,6 +725,8 @@ pub struct AdminEditorAssetSnapshot { pub created_at_micros: i64, pub updated_at_micros: i64, pub group_task_id: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -749,6 +772,8 @@ pub struct EditorShowcaseAssetSnapshot { pub rejected_at_micros: Option, pub updated_at_micros: i64, pub showcase_category: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -830,6 +855,8 @@ pub struct EditorAssetCreateInput { pub generation_cost_mud_points: u64, pub group_task_id: Option, pub group_task_expected_asset_count: Option, + pub image_sequence_frames_json: Option, + pub image_sequence_duration_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -2082,6 +2109,9 @@ fn create_editor_project_resource( let task_id = normalize_optional(input.task_id); let asset_kind = normalize_optional(input.asset_kind); let generation_inputs_json = normalize_optional(input.generation_inputs_json); + let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); + let image_sequence_duration_ms = input.image_sequence_duration_ms; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(existing_resource) = find_reusable_project_resource_for_input( ctx, project_id.as_str(), @@ -2092,8 +2122,20 @@ fn create_editor_project_resource( object_key.as_ref(), image_src.as_str(), ) { + let existing_resource = backfill_reusable_project_resource_media_fields( + ctx, + existing_resource, + &image_sequence_frames_json, + image_sequence_duration_ms, + now, + )?; return Ok(resource_snapshot_from_row(existing_resource)); } + validate_editor_media_metadata( + asset_kind.as_deref(), + &image_sequence_frames_json, + image_sequence_duration_ms, + )?; if ctx .db .editor_project_resource() @@ -2105,7 +2147,6 @@ fn create_editor_project_resource( } let public_showcase_enabled = false; - let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .editor_project_resource() .insert(EditorProjectResource { @@ -2129,6 +2170,8 @@ fn create_editor_project_resource( asset_kind, generation_inputs_json, public_showcase_enabled, + image_sequence_frames_json, + image_sequence_duration_ms, }); ctx.db @@ -2186,6 +2229,8 @@ fn repair_editor_project_resource_media( asset_kind: resource.asset_kind, generation_inputs_json: resource.generation_inputs_json, public_showcase_enabled: resource.public_showcase_enabled, + image_sequence_frames_json: resource.image_sequence_frames_json, + image_sequence_duration_ms: resource.image_sequence_duration_ms, }); ctx.db @@ -2608,6 +2653,13 @@ fn create_editor_asset( if group_task_id.is_some() { require_editor_generation_runtime_service_identity(ctx, caller)?; } + let asset_kind = normalize_optional(input.asset_kind); + let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); + validate_editor_media_metadata( + asset_kind.as_deref(), + &image_sequence_frames_json, + input.image_sequence_duration_ms, + )?; ctx.db.editor_asset().insert(EditorAsset { asset_id: asset_id.clone(), owner_user_id, @@ -2626,13 +2678,15 @@ fn create_editor_asset( task_id, created_at: now, updated_at: now, - asset_kind: normalize_optional(input.asset_kind), + asset_kind, generation_inputs_json: normalize_optional(input.generation_inputs_json), source_resource_id, thumbnail_src: normalize_optional(input.thumbnail_src), generation_cost_mud_points: input.generation_cost_mud_points, group_task_id, group_task_expected_asset_count, + image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, }); let asset = ctx .db @@ -2687,6 +2741,8 @@ fn update_editor_asset( generation_cost_mud_points: asset.generation_cost_mud_points, group_task_id: asset.group_task_id, group_task_expected_asset_count: asset.group_task_expected_asset_count, + image_sequence_frames_json: asset.image_sequence_frames_json, + image_sequence_duration_ms: asset.image_sequence_duration_ms, }); ctx.db .editor_asset() @@ -2735,6 +2791,8 @@ fn repair_editor_asset_media( generation_cost_mud_points: asset.generation_cost_mud_points, group_task_id: asset.group_task_id, group_task_expected_asset_count: asset.group_task_expected_asset_count, + image_sequence_frames_json: asset.image_sequence_frames_json, + image_sequence_duration_ms: asset.image_sequence_duration_ms, }); ctx.db @@ -2811,9 +2869,30 @@ fn submit_editor_showcase_asset( } let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .editor_showcase_asset() + .insert(build_pending_editor_showcase_asset( + asset, + showcase_id.clone(), + now, + )); + + ctx.db + .editor_showcase_asset() + .showcase_id() + .find(&showcase_id) + .map(showcase_snapshot_from_row) + .ok_or_else(|| "精选审核记录创建失败".to_string()) +} + +fn build_pending_editor_showcase_asset( + asset: EditorAsset, + showcase_id: String, + now: Timestamp, +) -> EditorShowcaseAsset { let refund_mud_points = asset.generation_cost_mud_points.saturating_add(1) / 2; - ctx.db.editor_showcase_asset().insert(EditorShowcaseAsset { - showcase_id: showcase_id.clone(), + EditorShowcaseAsset { + showcase_id, asset_id: asset.asset_id, owner_user_id: asset.owner_user_id, label: asset.label, @@ -2848,14 +2927,9 @@ fn submit_editor_showcase_asset( rejected_at: None, updated_at: now, showcase_category: None, - }); - - ctx.db - .editor_showcase_asset() - .showcase_id() - .find(&showcase_id) - .map(showcase_snapshot_from_row) - .ok_or_else(|| "精选审核记录创建失败".to_string()) + image_sequence_frames_json: asset.image_sequence_frames_json, + image_sequence_duration_ms: asset.image_sequence_duration_ms, + } } fn list_public_editor_showcase_assets( @@ -2916,17 +2990,90 @@ pub(crate) fn asset_object_has_public_showcase_read_grant( .editor_showcase_asset() .by_editor_showcase_asset_owner_user_id() .filter(owner_user_id) - .filter(is_public_showcase_asset) - .any(|asset| { - module_assets::asset_object_matches_public_read_grant( - &asset_object, - &module_assets::PublicAssetReadGrant { - owner_user_id: asset.owner_user_id, - asset_object_id: asset.asset_object_id, - object_key: asset.object_key, - }, - ) - }) + .any(|asset| public_showcase_asset_grants_asset_object(&asset, &asset_object)) +} + +fn public_showcase_asset_grants_asset_object( + asset: &EditorShowcaseAsset, + asset_object: &module_assets::AssetObjectRecord, +) -> bool { + if !is_public_showcase_asset(asset) { + return false; + } + if module_assets::asset_object_matches_public_read_grant( + asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: asset.owner_user_id.clone(), + asset_object_id: asset.asset_object_id.clone(), + object_key: asset.object_key.clone(), + }, + ) { + return true; + } + + if asset.asset_kind.as_deref() != Some("character-animation") + || asset + .image_sequence_duration_ms + .is_none_or(|duration_ms| duration_ms == 0) + { + return false; + } + let Some(frames_json) = asset.image_sequence_frames_json.as_deref() else { + return false; + }; + let Ok(frames) = serde_json::from_str::(frames_json) else { + return false; + }; + let Some(frames) = frames.as_array() else { + return false; + }; + if frames.len() < 2 + || !frames.iter().all(is_valid_editor_sequence_frame) + || !frames + .iter() + .all(editor_sequence_frame_has_stable_asset_reference) + { + return false; + } + + frames.iter().any(|frame| { + let frame = frame + .as_object() + .expect("validated editor sequence frame must be an object"); + let asset_object_id = frame + .get("assetObjectId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let object_key = frame + .get("objectKey") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + module_assets::asset_object_matches_public_read_grant( + asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: asset.owner_user_id.clone(), + asset_object_id, + object_key, + }, + ) + }) +} + +fn editor_sequence_frame_has_stable_asset_reference(frame: &JsonValue) -> bool { + let Some(frame) = frame.as_object() else { + return false; + }; + ["assetObjectId", "objectKey"].iter().any(|field| { + frame + .get(*field) + .and_then(JsonValue::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) } pub(crate) fn asset_location_has_public_showcase_read_grant( @@ -4458,6 +4605,8 @@ fn showcase_snapshot_from_row(row: EditorShowcaseAsset) -> EditorShowcaseAssetSn rejected_at_micros: timestamp_option_micros(row.rejected_at), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), showcase_category: row.showcase_category, + image_sequence_frames_json: row.image_sequence_frames_json, + image_sequence_duration_ms: row.image_sequence_duration_ms, } } @@ -4784,6 +4933,8 @@ fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAsse showcase_display_enabled: showcase_asset.as_ref().map(|asset| asset.display_enabled), showcase_like_count: showcase_asset.as_ref().map(|asset| asset.like_count), group_task_id: row.group_task_id, + image_sequence_frames_json: row.image_sequence_frames_json, + image_sequence_duration_ms: row.image_sequence_duration_ms, } } @@ -4815,6 +4966,8 @@ fn admin_asset_snapshot_from_row( created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), group_task_id, + image_sequence_frames_json: row.image_sequence_frames_json, + image_sequence_duration_ms: row.image_sequence_duration_ms, } } @@ -4991,6 +5144,141 @@ fn find_reusable_project_resource_for_input( matches.into_iter().next() } +fn merge_reusable_project_resource_media_field( + existing: &Option, + incoming: &Option, + field: &str, +) -> Result, String> { + match (existing, incoming) { + (Some(existing), Some(incoming)) if existing != incoming => { + Err(format!("同源画布资源的 {field} 与新提交元数据不一致")) + } + (Some(existing), _) => Ok(Some(existing.clone())), + (None, incoming) => Ok(incoming.clone()), + } +} + +fn merge_reusable_project_resource_media_fields( + existing: EditorProjectResource, + image_sequence_frames_json: &Option, + image_sequence_duration_ms: Option, + now: Timestamp, +) -> Result<(EditorProjectResource, bool), String> { + let next_image_sequence_frames_json = merge_reusable_project_resource_media_field( + &existing.image_sequence_frames_json, + image_sequence_frames_json, + "image_sequence_frames_json", + )?; + let next_image_sequence_duration_ms = merge_reusable_project_resource_media_field( + &existing.image_sequence_duration_ms, + &image_sequence_duration_ms, + "image_sequence_duration_ms", + )?; + validate_editor_media_metadata( + existing.asset_kind.as_deref(), + &next_image_sequence_frames_json, + next_image_sequence_duration_ms, + )?; + let changed = next_image_sequence_frames_json != existing.image_sequence_frames_json + || next_image_sequence_duration_ms != existing.image_sequence_duration_ms; + if !changed { + return Ok((existing, false)); + } + let next_updated_at = + if now.to_micros_since_unix_epoch() >= existing.updated_at.to_micros_since_unix_epoch() { + now + } else { + existing.updated_at + }; + Ok(( + EditorProjectResource { + updated_at: next_updated_at, + image_sequence_frames_json: next_image_sequence_frames_json, + image_sequence_duration_ms: next_image_sequence_duration_ms, + ..existing + }, + true, + )) +} + +fn backfill_reusable_project_resource_media_fields( + ctx: &ReducerContext, + existing: EditorProjectResource, + image_sequence_frames_json: &Option, + image_sequence_duration_ms: Option, + now: Timestamp, +) -> Result { + let (merged, changed) = merge_reusable_project_resource_media_fields( + existing, + image_sequence_frames_json, + image_sequence_duration_ms, + now, + )?; + if changed { + ctx.db + .editor_project_resource() + .resource_id() + .delete(&merged.resource_id); + ctx.db.editor_project_resource().insert(merged.clone()); + } + Ok(merged) +} + +fn validate_editor_media_metadata( + asset_kind: Option<&str>, + image_sequence_frames_json: &Option, + image_sequence_duration_ms: Option, +) -> Result<(), String> { + let has_sequence_metadata = + image_sequence_frames_json.is_some() || image_sequence_duration_ms.is_some(); + if !has_sequence_metadata { + if asset_kind == Some("character-animation") { + return Err("角色动作必须提供完整序列帧元数据".to_string()); + } + return Ok(()); + } + if asset_kind != Some("character-animation") { + return Err("只有角色动作素材可以提供序列帧元数据".to_string()); + } + + let frames_json = image_sequence_frames_json + .as_deref() + .ok_or_else(|| "角色动作缺少 image_sequence_frames_json".to_string())?; + let frames = serde_json::from_str::(frames_json) + .map_err(|_| "角色动作 image_sequence_frames_json 不是合法 JSON".to_string())?; + let frames = frames + .as_array() + .ok_or_else(|| "角色动作 image_sequence_frames_json 必须是数组".to_string())?; + if frames.len() < 2 || !frames.iter().all(is_valid_editor_sequence_frame) { + return Err("角色动作序列至少需要两帧,且不能包含无效帧".to_string()); + } + let image_sequence_duration_ms = image_sequence_duration_ms + .ok_or_else(|| "角色动作缺少 image_sequence_duration_ms".to_string())?; + if image_sequence_duration_ms == 0 { + return Err("角色动作 image_sequence_duration_ms 必须大于 0".to_string()); + } + Ok(()) +} + +fn is_valid_editor_sequence_frame(frame: &JsonValue) -> bool { + let Some(frame) = frame.as_object() else { + return false; + }; + frame + .get("imageSrc") + .and_then(JsonValue::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + && frame + .get("width") + .and_then(JsonValue::as_u64) + .is_some_and(|value| value > 0 && u32::try_from(value).is_ok()) + && frame + .get("height") + .and_then(JsonValue::as_u64) + .is_some_and(|value| value > 0 && u32::try_from(value).is_ok()) +} + fn project_resource_matches_reuse_input( resource: &EditorProjectResource, owner_user_id: &str, @@ -5077,6 +5365,8 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour public_showcase_enabled: row.public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + image_sequence_frames_json: row.image_sequence_frames_json, + image_sequence_duration_ms: row.image_sequence_duration_ms, } } @@ -5435,14 +5725,6 @@ fn validate_self_contained_legacy_local_image_sequence( index + 1 ) })?; - let expected_frame_index = u64::try_from(index + 1) - .map_err(|_| format!("本地图片序列 {resource_id} 的帧数过多"))?; - if frame.get("frameIndex").and_then(JsonValue::as_u64) != Some(expected_frame_index) { - return Err(format!( - "本地图片序列 {resource_id} 的第 {} 帧序号无效,拒绝结构化保存", - index + 1 - )); - } for field in ["width", "height"] { if frame .get(field) @@ -6999,7 +7281,7 @@ fn prepare_editor_canvas_restored_resources( for action in restores { let item = find_unique_resource_repair_layer(items, action.layer_id.as_str())?; let asset_kind = validate_editor_canvas_audio_asset_kind(action.asset_kind.as_str())?; - let resource = build_restored_audio_project_resource( + let expected_resource = build_restored_audio_project_resource( ctx, item, project_id, @@ -7013,16 +7295,20 @@ fn prepare_editor_canvas_restored_resources( .editor_project_resource() .resource_id() .find(&action.resource_id); - if already_repaired { + let resource = if already_repaired { let Some(existing) = existing else { return Err("音频项目资源尚未恢复,不能视为已修复".to_string()); }; - if !restored_project_resource_matches(&existing, &resource) { + if !restored_project_resource_matches(&existing, &expected_resource) { return Err("已恢复音频项目资源与修复计划不一致".to_string()); } - } else if existing.is_some() { - return Err("待恢复的音频 resourceId 已存在,拒绝覆盖".to_string()); - } + existing + } else { + if existing.is_some() { + return Err("待恢复的音频 resourceId 已存在,拒绝覆盖".to_string()); + } + expected_resource + }; resources.push(resource); } Ok(resources) @@ -7132,6 +7418,8 @@ fn build_restored_audio_project_resource( asset_kind: Some(asset_kind.to_string()), generation_inputs_json: None, public_showcase_enabled: false, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, }) } @@ -7169,6 +7457,8 @@ fn restored_project_resource_matches( && existing.asset_kind == expected.asset_kind && existing.generation_inputs_json == expected.generation_inputs_json && existing.public_showcase_enabled == expected.public_showcase_enabled + && existing.image_sequence_frames_json == expected.image_sequence_frames_json + && existing.image_sequence_duration_ms == expected.image_sequence_duration_ms } fn validate_repaired_editor_canvas_layout_resources( @@ -7187,11 +7477,10 @@ fn validate_repaired_editor_canvas_layout_resources( .resource_id() .find(&layer.resource_id) .filter(|row| row.project_id == project_id && row.owner_user_id == owner_user_id); - let resource = stored.as_ref().or_else(|| { - planned_resources - .iter() - .find(|row| row.resource_id == layer.resource_id) - }); + let resource = planned_resources + .iter() + .find(|row| row.resource_id == layer.resource_id) + .or(stored.as_ref()); let normalized = normalize_structured_canvas_layer_against_resource(layer, resource)?; if normalized.is_none() { let item = serde_json::from_str::>(&layer.item_json) @@ -8238,6 +8527,96 @@ mod tests { assert!(validate_editor_canvas_audio_asset_kind("audio").is_err()); } + #[test] + fn reusable_project_resource_backfills_missing_media_fields_and_rejects_conflicts() { + let existing = EditorProjectResource { + asset_kind: Some("character-animation".to_string()), + ..generated_editor_project_resource("resource-animation", "task-animation", None) + }; + let frames = Some(test_editor_sequence_frames_json(32)); + let backfilled_at = Timestamp::from_micros_since_unix_epoch(2); + let (backfilled, changed) = merge_reusable_project_resource_media_fields( + existing, + &frames, + Some(4_000), + backfilled_at, + ) + .expect("missing reusable metadata should backfill"); + assert!(changed); + assert_eq!(backfilled.image_sequence_frames_json, frames); + assert_eq!(backfilled.image_sequence_duration_ms, Some(4_000)); + assert_eq!(backfilled.updated_at, backfilled_at); + + let unchanged_at = backfilled.updated_at; + let (unchanged, changed) = merge_reusable_project_resource_media_fields( + backfilled.clone(), + &backfilled.image_sequence_frames_json, + backfilled.image_sequence_duration_ms, + Timestamp::from_micros_since_unix_epoch(3), + ) + .expect("matching reusable metadata should remain idempotent"); + assert!(!changed); + assert_eq!(unchanged.updated_at, unchanged_at); + + let error = merge_reusable_project_resource_media_fields( + backfilled, + &frames, + Some(5_000), + Timestamp::from_micros_since_unix_epoch(4), + ) + .err() + .expect("conflicting reusable duration must fail closed"); + assert!(error.contains("image_sequence_duration_ms")); + } + + #[test] + fn reusable_project_resource_backfill_never_moves_updated_at_backwards() { + let existing = EditorProjectResource { + updated_at: Timestamp::from_micros_since_unix_epoch(100), + asset_kind: Some("character-animation".to_string()), + ..generated_editor_project_resource("resource-animation", "task-animation", None) + }; + let frames = Some(test_editor_sequence_frames_json(32)); + + let (backfilled, changed) = merge_reusable_project_resource_media_fields( + existing, + &frames, + Some(4_000), + Timestamp::from_micros_since_unix_epoch(50), + ) + .expect("older retry should still backfill metadata"); + + assert!(changed); + assert_eq!( + backfilled.updated_at, + Timestamp::from_micros_since_unix_epoch(100) + ); + } + + #[test] + fn editor_media_metadata_validation_enforces_sequence_shape_and_positive_duration() { + let frames = Some(test_editor_sequence_frames_json(32)); + validate_editor_media_metadata(Some("character-animation"), &frames, Some(4_000)) + .expect("complete character animation metadata should pass"); + validate_editor_media_metadata(Some("video"), &None, None) + .expect("non-sequence media does not carry sequence metadata"); + + validate_editor_media_metadata( + Some("character-animation"), + &Some("[]".to_string()), + Some(1_000), + ) + .expect_err("empty sequence must fail closed"); + validate_editor_media_metadata(Some("character-animation"), &None, None) + .expect_err("character animation without sequence metadata must fail closed"); + validate_editor_media_metadata(Some("character-animation"), &frames, None) + .expect_err("character animation without duration must fail closed"); + validate_editor_media_metadata(Some("character-animation"), &frames, Some(0)) + .expect_err("zero sequence duration must fail closed"); + validate_editor_media_metadata(Some("video"), &frames, Some(4_000)) + .expect_err("non-character asset cannot persist sequence metadata"); + } + #[test] fn structured_canvas_layout_splits_layers_dialogs_and_settings() { let layout = json!([ @@ -8592,7 +8971,6 @@ mod tests { ("/0/sourceType", json!("uploaded")), ("/0/mediaType", json!("image")), ("/0/imageSequenceFrames", json!([])), - ("/0/imageSequenceFrames/0/frameIndex", json!(0)), ("/0/imageSequenceFrames/0/width", json!(0)), ("/0/imageSequenceFrames/0/height", json!(-1)), ("/0/src", json!("/generated/sequence/other.png")), @@ -8610,12 +8988,6 @@ mod tests { mismatched_frame_key[0]["imageSequenceFrames"][0]["objectKey"] = json!("generated/sequence/other.png"); invalid_layouts.push(mismatched_frame_key); - let mut repeated_frame_index = base.clone(); - let repeated_frame = repeated_frame_index[0]["imageSequenceFrames"][0].clone(); - repeated_frame_index[0]["imageSequenceFrames"] = - JsonValue::Array(vec![repeated_frame.clone(), repeated_frame]); - invalid_layouts.push(repeated_frame_index); - for invalid in invalid_layouts { normalize_single_layer_without_resource(&invalid) .expect_err("invalid local sequence must fail closed"); @@ -8646,6 +9018,8 @@ mod tests { asset_kind: None, generation_inputs_json: None, public_showcase_enabled: true, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, }; let layout = json!([{ "layerId": "layer-1", @@ -8810,6 +9184,8 @@ mod tests { asset_kind: optional(13)?, generation_inputs_json: optional(14)?, public_showcase_enabled: true, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, }) } @@ -9167,6 +9543,8 @@ mod tests { rejected_at: None, updated_at: now, showcase_category: Some("characters".to_string()), + image_sequence_frames_json: None, + image_sequence_duration_ms: None, } } @@ -9274,6 +9652,8 @@ mod tests { generation_cost_mud_points, group_task_id: None, group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, } } @@ -9303,9 +9683,26 @@ mod tests { asset_kind: Some("icon".to_string()), generation_inputs_json: None, public_showcase_enabled: false, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, } } + fn test_editor_sequence_frames_json(frame_count: u32) -> String { + serde_json::to_string( + &(1..=frame_count) + .map(|frame_index| { + json!({ + "imageSrc": format!("/generated/action/frame-{frame_index:02}.png"), + "width": 192, + "height": 256, + }) + }) + .collect::>(), + ) + .expect("test sequence frames should serialize") + } + #[test] fn editor_project_resource_reuse_rejects_different_asset_kind() { let resource = generated_editor_project_resource( @@ -9658,6 +10055,186 @@ mod tests { })); } + fn showcase_asset_object( + owner_user_id: &str, + asset_object_id: &str, + object_key: &str, + ) -> module_assets::AssetObjectRecord { + module_assets::AssetObjectRecord { + asset_object_id: asset_object_id.to_string(), + bucket: "genarrative-assets".to_string(), + object_key: object_key.to_string(), + access_policy: module_assets::AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1024, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: Some(owner_user_id.to_string()), + profile_id: None, + entity_id: None, + asset_kind: "character-animation-frame".to_string(), + created_at: "2026-07-31T00:00:00Z".to_string(), + updated_at: "2026-07-31T00:00:00Z".to_string(), + } + } + + #[test] + fn public_showcase_read_grant_includes_each_frozen_character_action_frame() { + let mut showcase = public_showcase_asset(); + showcase.asset_kind = Some("character-animation".to_string()); + showcase.image_sequence_duration_ms = Some(5_000); + showcase.image_sequence_frames_json = Some( + serde_json::to_string(&json!([ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "assetObjectId": "asset-object-frame-01", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/action/frame-02.png", + "objectKey": "generated/action/frame-02.png", + "assetObjectId": "asset-object-frame-02", + "width": 192, + "height": 256 + } + ])) + .expect("sequence frames should serialize"), + ); + + assert!(public_showcase_asset_grants_asset_object( + &showcase, + &showcase_asset_object( + "user-owner", + "asset-object-frame-02", + "generated/action/frame-02.png", + ), + )); + assert!(!public_showcase_asset_grants_asset_object( + &showcase, + &showcase_asset_object( + "user-owner", + "asset-object-unrelated", + "generated/action/unrelated.png", + ), + )); + assert!(!public_showcase_asset_grants_asset_object( + &showcase, + &showcase_asset_object( + "another-owner", + "asset-object-frame-02", + "generated/action/frame-02.png", + ), + )); + } + + #[test] + fn public_showcase_character_action_frame_grant_fails_closed_and_revokes_with_display() { + let mut showcase = public_showcase_asset(); + showcase.asset_kind = Some("character-animation".to_string()); + showcase.image_sequence_duration_ms = Some(5_000); + showcase.image_sequence_frames_json = Some( + serde_json::to_string(&json!([ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "width": 192, + "height": 256 + } + ])) + .expect("sequence frames should serialize"), + ); + let frame = showcase_asset_object( + "user-owner", + "asset-object-frame-01", + "generated/action/frame-01.png", + ); + + assert!(!public_showcase_asset_grants_asset_object( + &showcase, &frame + )); + + showcase.image_sequence_frames_json = Some( + serde_json::to_string(&json!([ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/action/frame-02.png", + "width": 192, + "height": 256 + } + ])) + .expect("sequence frames should serialize"), + ); + assert!(!public_showcase_asset_grants_asset_object( + &showcase, &frame + )); + + showcase.image_sequence_frames_json = Some( + serde_json::to_string(&json!([ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/action/frame-02.png", + "objectKey": "generated/action/frame-02.png", + "width": 192, + "height": 256 + } + ])) + .expect("sequence frames should serialize"), + ); + showcase.display_enabled = false; + assert!(!public_showcase_asset_grants_asset_object( + &showcase, &frame + )); + } + + #[test] + fn showcase_submission_snapshot_preserves_character_action_metadata() { + let frames_json = test_editor_sequence_frames_json(40); + let asset = EditorAsset { + image_sequence_frames_json: Some(frames_json.clone()), + image_sequence_duration_ms: Some(5_000), + ..generated_editor_asset( + "action-asset", + "action-task", + "character-animation", + "generated-character-actions/action/frame-0001.png", + 0, + 1, + ) + }; + let submitted_at = Timestamp::from_micros_since_unix_epoch(2_000_000); + + let row = build_pending_editor_showcase_asset( + asset, + "editor-showcase:action-asset".to_string(), + submitted_at, + ); + assert_eq!( + row.image_sequence_frames_json.as_deref(), + Some(frames_json.as_str()) + ); + assert_eq!(row.image_sequence_duration_ms, Some(5_000)); + + let snapshot = showcase_snapshot_from_row(row); + assert_eq!( + snapshot.image_sequence_frames_json.as_deref(), + Some(frames_json.as_str()) + ); + assert_eq!(snapshot.image_sequence_duration_ms, Some(5_000)); + } + #[test] fn campaign_read_grant_matches_only_enabled_current_object_key_without_metadata() { let campaign = showcase_campaign_config( diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 7cd9f2c16..96ae764e5 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1366,6 +1366,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("showcase_category".to_string()) .or_insert(serde_json::Value::Null); + // 中文注释:角色动作正式序列字段晚于精选快照表加入,旧记录按无正式序列兼容。 + for field in ["image_sequence_frames_json", "image_sequence_duration_ms"] { + object + .entry(field.to_string()) + .or_insert(serde_json::Value::Null); + } } } if table_name == "editor_canvas" { @@ -1552,6 +1558,11 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("public_showcase_enabled".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); + for field in ["image_sequence_frames_json", "image_sequence_duration_ms"] { + object + .entry(field.to_string()) + .or_insert(serde_json::Value::Null); + } } } if table_name == "editor_asset" { @@ -1570,6 +1581,11 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("group_task_expected_asset_count".to_string()) .or_insert(serde_json::Value::Null); + for field in ["image_sequence_frames_json", "image_sequence_duration_ms"] { + object + .entry(field.to_string()) + .or_insert(serde_json::Value::Null); + } } } next_value