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 01/42] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=AD=A3=E5=BC=8F=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=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 -- 2.52.0 From 230613b07ae9bf285766d6bf5c7fdd5f1c05616d 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:17:10 +0800 Subject: [PATCH 02/42] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=AD=98=E9=87=8F=E6=95=B0=E6=8D=AE=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 migration operator 专用 asset、project-resource、showcase、canvas 四阶段 procedure。 顶层旧字段仅在动作身份已证明后解释,正式与旧版冲突形成 blocker。 apply 绑定同批 dry-run SHA-256,并在结束后执行零匹配与零 blocker 复核。 新增迁移脚本、SATS 解析测试、生成绑定和生产制品门禁。 --- .../Jenkinsfile.production-stdb-module-build | 2 +- ...Jenkinsfile.production-stdb-module-publish | 2 +- scripts/build-production-release.sh | 1 + scripts/check-production-ops-guardrails.mjs | 25 + scripts/spacetime-migration-common.mjs | 27 + scripts/spacetime-migration-common.test.ts | 49 + ...ime-normalize-editor-character-actions.mjs | 233 ++ ...normalize-editor-character-actions.test.ts | 143 + .../spacetime-client/src/module_bindings.rs | 6 + ...cter_animation_normalization_input_type.rs | 19 + ...ion_normalization_procedure_result_type.rs | 33 + ...animation_metadata_and_return_procedure.rs | 61 + .../src/editor_project_storage.rs | 2480 +++++++++++++---- 13 files changed, 2589 insertions(+), 492 deletions(-) create mode 100644 scripts/spacetime-normalize-editor-character-actions.mjs create mode 100644 scripts/spacetime-normalize-editor-character-actions.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 47d282eb8..6d748c4ef 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -153,7 +153,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index f2222f1d5..9e6be03ea 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -93,7 +93,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index 2ced31812..455636667 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本" copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本" copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本" copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本" copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本" diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 00d92b4b3..6397b6a46 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -580,6 +580,21 @@ const checks = [ includes: 'dry_run: !options.apply', reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。', }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']", + reason: '角色动作规范化必须按固定依赖顺序执行四个 scope。', + }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: 'expectedBatchSha256: dryRun.batch_sha256', + reason: '角色动作规范化 apply 必须绑定同批 dry-run 返回的摘要。', + }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: "await scanScopes(options, { verifyZero: true })", + reason: '角色动作规范化 apply 后必须执行全量零匹配复核。', + }, { file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)', @@ -600,11 +615,21 @@ const checks = [ includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', reason: 'Stdb Build 必须归档图片画布存量迁移脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'scripts/spacetime-normalize-editor-character-actions.mjs', + reason: 'Stdb Build 必须归档角色动作元数据规范化脚本。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-publish', includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: 'scripts/spacetime-normalize-editor-character-actions.mjs', + reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs', diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 435dda916..d1366a0a3 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -292,6 +292,33 @@ function normalizeProcedureResult(value, procedureName) { } function normalizeSatsProduct(value, procedureName) { + if ( + procedureName === 'normalize_editor_character_animation_metadata_and_return' && + value.length === 19 + ) { + return { + ok: normalizeSatsValue(value[0]), + scope: normalizeSatsValue(value[1]), + dry_run: normalizeSatsValue(value[2]), + scanned_count: normalizeSatsValue(value[3]), + matched_count: normalizeSatsValue(value[4]), + updated_count: normalizeSatsValue(value[5]), + backfilled_frames_count: normalizeSatsValue(value[6]), + backfilled_duration_count: normalizeSatsValue(value[7]), + reclassified_preview_count: normalizeSatsValue(value[8]), + cleaned_generation_inputs_count: normalizeSatsValue(value[9]), + cleaned_frame_index_count: normalizeSatsValue(value[10]), + cleaned_canvas_layer_count: normalizeSatsValue(value[11]), + materialized_resource_count: normalizeSatsValue(value[12]), + blocker_count: normalizeSatsValue(value[13]), + blocker_ids: normalizeSatsValue(value[14]), + next_cursor: normalizeSatsOption(value[15]), + has_more: normalizeSatsValue(value[16]), + batch_sha256: normalizeSatsValue(value[17]), + error_message: normalizeSatsOption(value[18]), + }; + } + if ( procedureName === 'repair_editor_canvas_resources_and_return' && value.length === 3 diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 5ba7114ab..5a89d09f5 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -99,4 +99,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => { error_message: null, }); }); + + it('normalizes character action metadata normalization results', () => { + const result = parseProcedureResult( + JSON.stringify([ + true, + 'asset', + true, + 25, + 3, + 0, + 2, + 1, + 0, + 3, + 2, + 0, + 0, + 1, + ['asset-blocked'], + [0, 'asset-25'], + true, + 'a'.repeat(64), + [1], + ]), + 'normalize_editor_character_animation_metadata_and_return', + ); + + expect(result).toEqual({ + ok: true, + scope: 'asset', + dry_run: true, + scanned_count: 25, + matched_count: 3, + updated_count: 0, + backfilled_frames_count: 2, + backfilled_duration_count: 1, + reclassified_preview_count: 0, + cleaned_generation_inputs_count: 3, + cleaned_frame_index_count: 2, + cleaned_canvas_layer_count: 0, + materialized_resource_count: 0, + blocker_count: 1, + blocker_ids: ['asset-blocked'], + next_cursor: 'asset-25', + has_more: true, + batch_sha256: 'a'.repeat(64), + error_message: null, + }); + }); }); diff --git a/scripts/spacetime-normalize-editor-character-actions.mjs b/scripts/spacetime-normalize-editor-character-actions.mjs new file mode 100644 index 000000000..db7a0cad9 --- /dev/null +++ b/scripts/spacetime-normalize-editor-character-actions.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url'; + +import { + callSpacetimeProcedureViaCli, + encodeSpacetimeCliOption, + ensureProcedureOk, + parsePositiveInteger, +} from './spacetime-migration-common.mjs'; + +const PROCEDURE_NAME = 'normalize_editor_character_animation_metadata_and_return'; +const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']; +const DEFAULT_CHUNK_SIZE = 25; +const CANVAS_MAX_CHUNK_SIZE = 5; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; + +function usage() { + return `用法: + node scripts/spacetime-normalize-editor-character-actions.mjs \\ + --database --server [--chunk-size <1-25>] [--apply] + +默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。 +追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。 +apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。 +必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`; +} + +export function parseOptions(argv, env = process.env) { + const options = { + apply: false, + chunkSize: DEFAULT_CHUNK_SIZE, + database: env.GENARRATIVE_SPACETIME_DATABASE || '', + passthrough: [], + server: env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + if (arg === '--database') { + options.database = readValue(); + } else if (arg === '--server') { + options.server = readValue(); + } else if (arg === '--server-url') { + options.serverUrl = readValue(); + } else if (arg === '--chunk-size') { + options.chunkSize = parsePositiveInteger(readValue(), arg); + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--no-config' || arg === '--anonymous') { + options.passthrough.push(arg); + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new Error(`未知参数: ${arg}`); + } + } + if (options.chunkSize > DEFAULT_CHUNK_SIZE) { + throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`); + } + return options; +} + +export function buildNormalizationInput({ + scope, + cursor = null, + limit, + dryRun, + expectedBatchSha256 = null, +}) { + if (!SCOPES.includes(scope)) { + throw new Error(`未知角色动作规范化 scope: ${scope}`); + } + if (!Number.isInteger(limit) || limit < 1) { + throw new Error('角色动作规范化 limit 必须是正整数。'); + } + if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) { + throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`); + } + if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) { + throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。'); + } + return { + scope, + cursor: encodeSpacetimeCliOption(cursor), + limit, + dry_run: dryRun, + expected_batch_sha256: encodeSpacetimeCliOption( + dryRun ? null : expectedBatchSha256, + ), + }; +} + +function scopeLimit(scope, chunkSize) { + return scope === 'canvas' + ? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE) + : chunkSize; +} + +function assertSafeBatch(result, scope) { + ensureProcedureOk(result); + if (result.scope !== scope) { + throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`); + } + if (result.blocker_count !== 0 || result.blocker_ids.length !== 0) { + throw new Error( + `${scope} scope 存在 ${result.blocker_count} 个 blocker:${result.blocker_ids.join(', ')}`, + ); + } + if (!SHA256_PATTERN.test(result.batch_sha256 || '')) { + throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`); + } +} + +async function callBatch(options, input) { + return callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); +} + +export async function scanScopes( + options, + { apply = false, verifyZero = false, callProcedure = callBatch } = {}, +) { + const summaries = []; + for (const scope of SCOPES) { + let cursor = null; + const summary = { + scope, + scanned_count: 0, + matched_count: 0, + updated_count: 0, + batches: 0, + }; + do { + const limit = scopeLimit(scope, options.chunkSize); + const dryRun = await callProcedure( + options, + buildNormalizationInput({ scope, cursor, limit, dryRun: true }), + ); + assertSafeBatch(dryRun, scope); + summary.scanned_count += dryRun.scanned_count; + summary.matched_count += dryRun.matched_count; + summary.batches += 1; + + if (verifyZero && dryRun.matched_count !== 0) { + throw new Error( + `${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待规范化。`, + ); + } + if (apply && dryRun.matched_count > 0) { + const applied = await callProcedure( + options, + buildNormalizationInput({ + scope, + cursor, + limit, + dryRun: false, + expectedBatchSha256: dryRun.batch_sha256, + }), + ); + assertSafeBatch(applied, scope); + if (applied.batch_sha256 !== dryRun.batch_sha256) { + throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`); + } + if (applied.updated_count !== dryRun.matched_count) { + throw new Error( + `${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`, + ); + } + summary.updated_count += applied.updated_count; + } + cursor = dryRun.has_more ? dryRun.next_cursor : null; + if (dryRun.has_more && !cursor) { + throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`); + } + } while (cursor); + summaries.push(summary); + } + return summaries; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseOptions(argv); + if (options.help) { + console.log(usage()); + return; + } + if (!options.database) { + throw new Error('必须显式传入 --database。'); + } + if (!options.server && !options.serverUrl) { + throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。'); + } + + const migration = await scanScopes(options, { apply: options.apply }); + const verification = options.apply + ? await scanScopes(options, { verifyZero: true }) + : null; + console.log( + JSON.stringify( + { + procedure: PROCEDURE_NAME, + applied: options.apply, + scope_order: SCOPES, + migration, + verification, + }, + null, + 2, + ), + ); + if (!options.apply) { + console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。'); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { + console.error( + `[spacetime:editor-character-actions:normalize] ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/spacetime-normalize-editor-character-actions.test.ts b/scripts/spacetime-normalize-editor-character-actions.test.ts new file mode 100644 index 000000000..ccbda298c --- /dev/null +++ b/scripts/spacetime-normalize-editor-character-actions.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildNormalizationInput, + parseOptions, + scanScopes, +} from './spacetime-normalize-editor-character-actions.mjs'; + +describe('角色动作元数据规范化脚本', () => { + it('默认 dry-run 并要求调用方显式选择 apply', () => { + expect( + parseOptions( + ['--database', 'genarrative-prod', '--server', 'prod'], + {}, + ), + ).toMatchObject({ + apply: false, + chunkSize: 25, + database: 'genarrative-prod', + server: 'prod', + }); + }); + + it('编码 dry-run cursor 并禁止 canvas 超过五行', () => { + expect( + buildNormalizationInput({ + scope: 'asset', + cursor: 'asset-25', + limit: 25, + dryRun: true, + }), + ).toEqual({ + scope: 'asset', + cursor: [0, 'asset-25'], + limit: 25, + dry_run: true, + expected_batch_sha256: null, + }); + expect(() => + buildNormalizationInput({ + scope: 'canvas', + limit: 6, + dryRun: true, + }), + ).toThrow('canvas scope limit'); + }); + + it('apply 必须绑定 dry-run 返回的批次哈希', () => { + expect(() => + buildNormalizationInput({ + scope: 'showcase', + limit: 25, + dryRun: false, + }), + ).toThrow('batch SHA-256'); + + expect( + buildNormalizationInput({ + scope: 'showcase', + cursor: null, + limit: 25, + dryRun: false, + expectedBatchSha256: 'a'.repeat(64), + }), + ).toEqual({ + scope: 'showcase', + cursor: null, + limit: 25, + dry_run: false, + expected_batch_sha256: [0, 'a'.repeat(64)], + }); + }); + + it('按固定 scope 顺序为每批执行 hash 绑定的 dry-run 与 apply', async () => { + const calls: Array> = []; + const callProcedure = async ( + _options: Record, + input: Record, + ) => { + calls.push(input); + const dryRun = input.dry_run === true; + const scope = String(input.scope); + const hashDigit = { + asset: 'a', + 'project-resource': 'b', + showcase: 'c', + canvas: 'd', + }[scope]!; + const batchSha256 = hashDigit.repeat(64); + return { + ok: true, + scope, + dry_run: dryRun, + scanned_count: 1, + matched_count: 1, + updated_count: dryRun ? 0 : 1, + blocker_count: 0, + blocker_ids: [], + next_cursor: null, + has_more: false, + batch_sha256: batchSha256, + error_message: null, + }; + }; + + const summaries = await scanScopes( + { chunkSize: 25 }, + { apply: true, callProcedure }, + ); + + expect(summaries.map((summary) => summary.scope)).toEqual([ + 'asset', + 'project-resource', + 'showcase', + 'canvas', + ]); + expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([ + 'asset:true', + 'asset:false', + 'project-resource:true', + 'project-resource:false', + 'showcase:true', + 'showcase:false', + 'canvas:true', + 'canvas:false', + ]); + for (let index = 1; index < calls.length; index += 2) { + const apply = calls[index]!; + const scope = String(apply.scope); + const hashDigit = { + asset: 'a', + 'project-resource': 'b', + showcase: 'c', + canvas: 'd', + }[scope]!; + expect(apply.expected_batch_sha256).toEqual([ + 0, + hashDigit.repeat(64), + ]); + } + expect(calls.at(-2)?.limit).toBe(5); + }); +}); diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b7d5cd579..b7ad70668 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -287,6 +287,8 @@ pub mod editor_canvas_resource_repair_snapshot_type; pub mod editor_canvas_snapshot_type; pub mod editor_canvas_table; pub mod editor_canvas_type; +pub mod editor_character_animation_normalization_input_type; +pub mod editor_character_animation_normalization_procedure_result_type; pub mod editor_generation_model_pricing_type; pub mod editor_generation_pricing_config_procedure_result_type; pub mod editor_generation_pricing_config_snapshot_type; @@ -466,6 +468,7 @@ pub mod match_3_d_runtime_run_row_type; pub mod match_3_d_runtime_run_table; pub mod match_3_d_work_profile_row_type; pub mod match_3_d_work_profile_table; +pub mod normalize_editor_character_animation_metadata_and_return_procedure; pub mod npc_relation_stance_type; pub mod npc_relation_state_type; pub mod npc_stance_profile_type; @@ -1105,6 +1108,8 @@ pub use editor_canvas_resource_repair_snapshot_type::EditorCanvasResourceRepairS pub use editor_canvas_snapshot_type::EditorCanvasSnapshot; pub use editor_canvas_table::*; pub use editor_canvas_type::EditorCanvas; +pub use editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; +pub use editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; pub use editor_generation_model_pricing_type::EditorGenerationModelPricing; pub use editor_generation_pricing_config_procedure_result_type::EditorGenerationPricingConfigProcedureResult; pub use editor_generation_pricing_config_snapshot_type::EditorGenerationPricingConfigSnapshot; @@ -1284,6 +1289,7 @@ pub use match_3_d_runtime_run_row_type::Match3DRuntimeRunRow; pub use match_3_d_runtime_run_table::*; pub use match_3_d_work_profile_row_type::Match3DWorkProfileRow; pub use match_3_d_work_profile_table::*; +pub use normalize_editor_character_animation_metadata_and_return_procedure::normalize_editor_character_animation_metadata_and_return; pub use npc_relation_stance_type::NpcRelationStance; pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs new file mode 100644 index 000000000..b1fcef0e6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCharacterAnimationNormalizationInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha_256: Option, +} + +impl __sdk::InModule for EditorCharacterAnimationNormalizationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs new file mode 100644 index 000000000..541bbd69b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCharacterAnimationNormalizationProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub backfilled_frames_count: u32, + pub backfilled_duration_count: u32, + pub reclassified_preview_count: u32, + pub cleaned_generation_inputs_count: u32, + pub cleaned_frame_index_count: u32, + pub cleaned_canvas_layer_count: u32, + pub materialized_resource_count: u32, + pub blocker_count: u32, + pub blocker_ids: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha_256: String, + pub error_message: Option, +} + +impl __sdk::InModule for EditorCharacterAnimationNormalizationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs new file mode 100644 index 000000000..68712687d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; +use super::editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct NormalizeEditorCharacterAnimationMetadataAndReturnArgs { + pub input: EditorCharacterAnimationNormalizationInput, +} + +impl __sdk::InModule for NormalizeEditorCharacterAnimationMetadataAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `normalize_editor_character_animation_metadata_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait normalize_editor_character_animation_metadata_and_return { + fn normalize_editor_character_animation_metadata_and_return( + &self, + input: EditorCharacterAnimationNormalizationInput, + ) { + self.normalize_editor_character_animation_metadata_and_return_then(input, |_, _| {}); + } + + fn normalize_editor_character_animation_metadata_and_return_then( + &self, + input: EditorCharacterAnimationNormalizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl normalize_editor_character_animation_metadata_and_return for super::RemoteProcedures { + fn normalize_editor_character_animation_metadata_and_return_then( + &self, + input: EditorCharacterAnimationNormalizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, EditorCharacterAnimationNormalizationProcedureResult>( + "normalize_editor_character_animation_metadata_and_return", + NormalizeEditorCharacterAnimationMetadataAndReturnArgs { input, }, + __callback, + ); + } +} 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 e565fab21..3c96f34c7 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1,6 +1,7 @@ use crate::*; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::RangeFrom; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_CANVAS_DEFAULT_TITLE: &str = "默认画布"; @@ -17,6 +18,10 @@ const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back"; const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16; const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; +const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; +const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; +const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "character-animation"; +const EDITOR_CHARACTER_ANIMATION_OBJECT_KIND: &str = "editor_character_animation"; const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ "src", "imageSrc", @@ -94,7 +99,8 @@ pub struct EditorProject { #[spacetimedb::table( accessor = editor_canvas, index(accessor = by_editor_canvas_project_id, btree(columns = [project_id])), - index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])) + index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_editor_canvas_normalization_cursor, btree(columns = [canvas_id, project_id])) )] #[derive(Clone)] pub struct EditorCanvas { @@ -205,7 +211,8 @@ pub struct EditorCanvasLayoutMigration { #[spacetimedb::table( accessor = editor_project_resource, index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])), - index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])) + index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_editor_project_resource_normalization_cursor, btree(columns = [resource_id, project_id])) )] #[derive(Clone)] pub struct EditorProjectResource { @@ -259,7 +266,8 @@ pub struct EditorAssetFolder { #[spacetimedb::table( accessor = editor_asset, index(accessor = by_editor_asset_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])) + index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])), + index(accessor = by_editor_asset_normalization_cursor, btree(columns = [asset_id, owner_user_id])) )] pub struct EditorAsset { #[primary_key] @@ -326,7 +334,8 @@ pub struct EditorAssetGroupCohort { #[spacetimedb::table( accessor = editor_showcase_asset, index(accessor = by_editor_showcase_asset_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_showcase_asset_review_status, btree(columns = [review_status])) + index(accessor = by_editor_showcase_asset_review_status, btree(columns = [review_status])), + index(accessor = by_editor_showcase_asset_normalization_cursor, btree(columns = [showcase_id, asset_id])) )] pub struct EditorShowcaseAsset { #[primary_key] @@ -572,6 +581,38 @@ pub struct EditorCanvasResourceRepairProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCharacterAnimationNormalizationInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha256: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCharacterAnimationNormalizationProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub backfilled_frames_count: u32, + pub backfilled_duration_count: u32, + pub reclassified_preview_count: u32, + pub cleaned_generation_inputs_count: u32, + pub cleaned_frame_index_count: u32, + pub cleaned_canvas_layer_count: u32, + pub materialized_resource_count: u32, + pub blocker_count: u32, + pub blocker_ids: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha256: String, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceCreateInput { pub resource_id: String, @@ -1306,6 +1347,27 @@ pub fn repair_editor_canvas_resources_and_return( } } +#[spacetimedb::procedure] +pub fn normalize_editor_character_animation_metadata_and_return( + ctx: &mut ProcedureContext, + input: EditorCharacterAnimationNormalizationInput, +) -> EditorCharacterAnimationNormalizationProcedureResult { + let caller = ctx.sender(); + let fallback_scope = input.scope.clone(); + let fallback_dry_run = input.dry_run; + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + normalize_editor_character_animation_metadata(tx, input.clone()) + }) { + Ok(result) => result, + Err(message) => failed_editor_character_animation_normalization_result( + fallback_scope, + fallback_dry_run, + message, + ), + } +} + #[spacetimedb::procedure] pub fn rename_editor_project_and_return( ctx: &mut ProcedureContext, @@ -2015,7 +2077,6 @@ fn persist_editor_project_layout_inner( &mut structured_layout, now, true, - false, )?; let normalized_structured_layers_json = normalize_layout_json( serialize_structured_canvas_layout(&structured_layout) @@ -2111,6 +2172,7 @@ fn create_editor_project_resource( 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; + validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?; let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(existing_resource) = find_reusable_project_resource_for_input( ctx, @@ -2654,7 +2716,9 @@ fn create_editor_asset( require_editor_generation_runtime_service_identity(ctx, caller)?; } 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); + validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?; validate_editor_media_metadata( asset_kind.as_deref(), &image_sequence_frames_json, @@ -2679,7 +2743,7 @@ fn create_editor_asset( created_at: now, updated_at: now, asset_kind, - generation_inputs_json: normalize_optional(input.generation_inputs_json), + generation_inputs_json, source_resource_id, thumbnail_src: normalize_optional(input.thumbnail_src), generation_cost_mud_points: input.generation_cost_mud_points, @@ -5260,15 +5324,53 @@ fn validate_editor_media_metadata( Ok(()) } +fn validate_editor_generation_inputs( + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, +) -> Result<(), String> { + if asset_kind != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + return Ok(()); + } + let Some(generation_inputs_json) = generation_inputs_json else { + return Ok(()); + }; + let value = serde_json::from_str::(generation_inputs_json) + .map_err(|_| "角色动作 generation_inputs_json 不是合法 JSON".to_string())?; + let Some(inputs) = value.as_object() else { + return Ok(()); + }; + let rejected = [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] + .into_iter() + .filter(|field| inputs.contains_key(*field)) + .collect::>(); + if rejected.is_empty() { + Ok(()) + } else { + Err(format!( + "角色动作 generation_inputs_json 不能包含旧版运行字段:{}", + rejected.join(", ") + )) + } +} + 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.contains_key("frameIndex") + && 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) @@ -5672,15 +5774,7 @@ fn strip_canvas_layer_resource_metadata_for_canonical_hash( ) { continue; } - let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; - let source_type = json_optional_string(object.get("sourceType")); - if validate_self_contained_legacy_local_image_sequence( - resource_id.as_str(), - source_type.as_deref(), - object, - )? { - continue; - } + json_required_string(object.get("resourceId"), "resourceId")?; for field in EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS { object.remove(field); } @@ -5689,139 +5783,6 @@ fn strip_canvas_layer_resource_metadata_for_canonical_hash( Ok(()) } -fn validate_self_contained_legacy_local_image_sequence( - resource_id: &str, - source_type: Option<&str>, - item: &JsonMap, -) -> Result { - if !resource_id.starts_with("local-") - || source_type != Some("generated") - || item.get("mediaType").and_then(JsonValue::as_str) != Some("image-sequence") - { - return Ok(false); - } - - for field in ["imageSrc", "objectKey", "assetObjectId"] { - match item.get(field) { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if value.trim().is_empty() => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 包含不允许的 {field},拒绝结构化保存" - )); - } - } - } - - let frames = item - .get("imageSequenceFrames") - .and_then(JsonValue::as_array) - .filter(|frames| !frames.is_empty()) - .ok_or_else(|| format!("本地图片序列 {resource_id} 缺少有效帧,拒绝结构化保存"))?; - for (index, frame) in frames.iter().enumerate() { - let frame = frame.as_object().ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧不是对象,拒绝结构化保存", - index + 1 - ) - })?; - for field in ["width", "height"] { - if frame - .get(field) - .and_then(JsonValue::as_f64) - .filter(|value| value.is_finite() && *value > 0.0) - .is_none() - { - return Err(format!( - "本地图片序列 {resource_id} 的第 {} 帧 {field} 无效,拒绝结构化保存", - index + 1 - )); - } - } - let image_src = frame - .get("imageSrc") - .and_then(JsonValue::as_str) - .filter(|value| is_stable_canvas_object_path(value)) - .ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧不是稳定站内路径,拒绝结构化保存", - index + 1 - ) - })?; - if let Some(frame_object_key) = frame.get("objectKey") { - let frame_object_key = frame_object_key.as_str().ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧 objectKey 无效,拒绝结构化保存", - index + 1 - ) - })?; - if !is_stable_canvas_object_key(frame_object_key) - || frame_object_key.trim() != image_src.trim().trim_start_matches('/') - { - return Err(format!( - "本地图片序列 {resource_id} 的第 {} 帧 objectKey 与 imageSrc 不一致,拒绝结构化保存", - index + 1 - )); - } - } - } - - match item.get("src") { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if value.trim().is_empty() => {} - Some(JsonValue::String(value)) - if is_stable_canvas_object_path(value) - && value.trim() - == frames[0] - .as_object() - .and_then(|frame| frame.get("imageSrc")) - .and_then(JsonValue::as_str) - .map(str::trim) - .unwrap_or_default() => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 的 src 与首帧不一致,拒绝结构化保存" - )); - } - } - - for (field, label) in [("thumbnailSrc", "缩略图"), ("previewVideoPath", "预览视频")] { - match item.get(field) { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if is_stable_canvas_object_path(value) => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 的{label}不是稳定站内路径,拒绝结构化保存" - )); - } - } - } - - Ok(true) -} - -fn is_stable_canvas_object_path(value: &str) -> bool { - let value = value.trim(); - value.starts_with('/') - && !value.starts_with("//") - && !value.contains('?') - && !value.contains('#') - && !value.contains('\\') - && !value.split('/').any(|segment| segment == "..") - && !value.chars().any(char::is_control) -} - -fn is_stable_canvas_object_key(value: &str) -> bool { - let value = value.trim(); - !value.is_empty() - && !value.starts_with('/') - && !value.contains('?') - && !value.contains('#') - && !value.contains('\\') - && !value.split('/').any(|segment| segment == "..") - && !value.chars().any(char::is_control) -} - fn canonical_layout_sha256(layers_json: &str) -> Result { canonical_layout_json(layers_json).map(|value| sha256_hex(value.as_bytes())) } @@ -5876,31 +5837,34 @@ fn normalize_structured_canvas_layer_against_resource( ) -> Result, Option)>, String> { let mut item = serde_json::from_str::>(&layer.item_json) .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; - let is_self_contained_local_sequence = validate_self_contained_legacy_local_image_sequence( - layer.resource_id.as_str(), - layer.source_type.as_deref(), - &item, - )?; let Some(resource) = resource else { - if !is_self_contained_local_sequence { - return Err(format!( - "图片画布图层 {} 缺少项目资源 {},拒绝结构化保存", - layer.layer_id, layer.resource_id - )); - } - if layer.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { - return Err(format!( - "图片画布图层 {} 的非资源扩展数据超过 512 KiB", - layer.layer_id - )); - } - return Ok(None); - }; - if is_self_contained_local_sequence { return Err(format!( - "本地图片序列 {} 同时存在项目资源行,无法确定权威元数据,拒绝结构化保存", - layer.resource_id + "图片画布图层 {} 缺少项目资源 {},拒绝结构化保存", + layer.layer_id, layer.resource_id )); + }; + if resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + let rejected = [ + "assetKind", + "generationInputs", + "mediaType", + "thumbnailSrc", + "sourceAssetId", + "imageSequenceFrames", + "imageSequenceDurationMs", + "previewVideoPath", + "durationSeconds", + ] + .into_iter() + .filter(|field| item.contains_key(*field)) + .collect::>(); + if !rejected.is_empty() { + return Err(format!( + "图片画布角色动作图层 {} 不能复制资源结果字段:{}", + layer.layer_id, + rejected.join(", ") + )); + } } take_matching_optional_resource_string( @@ -5986,51 +5950,14 @@ fn normalize_structured_canvas_layer_against_resource( Ok(Some((layout_asset_kind, layout_generation_inputs))) } -fn legacy_local_sequence_extension_matches_existing_layer( - existing_source_type: Option<&str>, - existing_item_json: &str, - incoming: &StructuredCanvasLayerDraft, -) -> bool { - existing_source_type == incoming.source_type.as_deref() - && serde_json::from_str::(existing_item_json).ok() - == serde_json::from_str::(&incoming.item_json).ok() -} - -fn restore_omitted_legacy_local_sequence_metadata( - existing_item_json: &str, - incoming: &mut StructuredCanvasLayerDraft, -) -> Result<(), String> { - let existing = serde_json::from_str::>(existing_item_json) - .map_err(|_| "已迁移本地图片序列扩展数据不是合法对象".to_string())?; - let mut item = serde_json::from_str::>(&incoming.item_json) - .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", incoming.layer_id))?; - for field in ["assetKind", "generationInputs"] { - if !item.contains_key(field) - && let Some(value) = existing.get(field) - { - item.insert(field.to_string(), value.clone()); - } - } - incoming.item_json = serde_json::to_string(&JsonValue::Object(item)) - .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", incoming.layer_id))?; - if incoming.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { - return Err(format!( - "图片画布图层 {} 的非资源扩展数据超过 512 KiB", - incoming.layer_id - )); - } - Ok(()) -} - fn normalize_structured_canvas_layer_resource_metadata( ctx: &ReducerContext, - canvas_id: &str, + _canvas_id: &str, project_id: &str, owner_user_id: &str, layout: &mut StructuredCanvasLayout, now: Timestamp, apply_resource_migrations: bool, - allow_new_legacy_local_sequences: bool, ) -> Result<(), String> { let mut resource_metadata_updates = BTreeMap::, Option)>::new(); for layer in &mut layout.layers { @@ -6042,67 +5969,9 @@ fn normalize_structured_canvas_layer_resource_metadata( .filter(|resource| { resource.project_id == project_id && resource.owner_user_id == owner_user_id }); - let layout_resource_metadata = - normalize_structured_canvas_layer_against_resource(layer, resource.as_ref())?; - let Some((layout_asset_kind, layout_generation_inputs)) = layout_resource_metadata else { - let item = serde_json::from_str::>(&layer.item_json) - .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; - if let Some(source_resource_id) = json_optional_string(item.get("sourceResourceId")) - && let Some(source_resource) = ctx - .db - .editor_project_resource() - .resource_id() - .find(&source_resource_id) - && (source_resource.project_id != project_id - || source_resource.owner_user_id != owner_user_id) - { - return Err(format!( - "图片画布图层 {} 的 sourceResourceId 不属于当前工程,拒绝结构化保存", - layer.layer_id - )); - } - if !allow_new_legacy_local_sequences { - let existing_layer_key = - structured_canvas_row_key(canvas_id, "layer", layer.layer_id.as_str()); - let existing_layer = ctx - .db - .editor_canvas_layer() - .layer_key() - .find(&existing_layer_key); - let Some(existing_layer) = existing_layer else { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - }; - if existing_layer.canvas_id != canvas_id - || existing_layer.project_id != project_id - || existing_layer.owner_user_id != owner_user_id - || existing_layer.layer_id != layer.layer_id - || existing_layer.resource_id != layer.resource_id - { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - } - restore_omitted_legacy_local_sequence_metadata( - existing_layer.item_json.as_str(), - layer, - )?; - if !legacy_local_sequence_extension_matches_existing_layer( - existing_layer.source_type.as_deref(), - existing_layer.item_json.as_str(), - layer, - ) { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - } - } - continue; - }; + let (layout_asset_kind, layout_generation_inputs) = + normalize_structured_canvas_layer_against_resource(layer, resource.as_ref())? + .ok_or_else(|| "图片画布项目资源归一结果缺失".to_string())?; let Some(resource) = resource else { return Err("图片画布项目资源在元数据归一时消失".to_string()); }; @@ -7559,7 +7428,6 @@ fn backfill_editor_canvas_layout( &mut layout, now, !input.dry_run, - true, )?; let preview_json = serialize_structured_canvas_layout(&layout) .map_err(|_| "图片画布结构化回填预览无法序列化".to_string())?; @@ -8301,11 +8169,1783 @@ fn editor_generation_pricing_config_error( } } +#[derive(Clone, Debug, Default)] +struct EditorCharacterAnimationNormalizationStats { + scanned_count: u32, + matched_count: u32, + updated_count: u32, + backfilled_frames_count: u32, + backfilled_duration_count: u32, + reclassified_preview_count: u32, + cleaned_generation_inputs_count: u32, + cleaned_frame_index_count: u32, + cleaned_canvas_layer_count: u32, + materialized_resource_count: u32, + blocker_ids: Vec, + next_cursor: Option, + has_more: bool, + batch_sha256: String, +} + +#[derive(Clone, Debug)] +struct EditorCharacterAnimationRowPlan { + asset_kind: Option, + generation_inputs_json: Option, + image_sequence_frames_json: Option, + image_sequence_duration_ms: Option, + changed: bool, + backfilled_frames: bool, + backfilled_duration: bool, + reclassified_preview: bool, + cleaned_generation_inputs: bool, + cleaned_frame_index: bool, + blocker: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +struct EditorCharacterAnimationObjectEvidence { + is_action_object: bool, + is_preview_video: bool, +} + +fn normalization_cursor_range(cursor: Option<&str>) -> RangeFrom<&str> { + cursor + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default().. +} + +fn editor_character_animation_object_evidence( + ctx: &ReducerContext, + asset_object_id: Option<&str>, + owner_user_id: &str, + row_object_key: Option<&str>, +) -> EditorCharacterAnimationObjectEvidence { + let Some(asset_object_id) = asset_object_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return EditorCharacterAnimationObjectEvidence::default(); + }; + let Some(object) = + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + else { + return EditorCharacterAnimationObjectEvidence::default(); + }; + let owner_matches = object.owner_user_id.as_deref() == Some(owner_user_id); + let object_key_matches = row_object_key + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .is_none_or(|value| value == object.object_key.trim_start_matches('/')); + let is_action_object = owner_matches + && object_key_matches + && object.asset_kind == EDITOR_CHARACTER_ANIMATION_OBJECT_KIND; + let is_preview_video = is_action_object + && object + .content_type + .as_deref() + .is_some_and(|content_type| content_type.starts_with("video/")); + EditorCharacterAnimationObjectEvidence { + is_action_object, + is_preview_video, + } +} + +fn normalize_migration_image_sequence_frames(value: &JsonValue) -> Option<(String, bool)> { + let frames = value.as_array()?; + if frames.len() < 2 { + return None; + } + let mut had_frame_index = false; + let normalized = frames + .iter() + .map(|frame| { + let frame = frame.as_object()?; + had_frame_index |= frame.contains_key("frameIndex"); + let image_src = frame + .get("imageSrc") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let width = frame.get("width").and_then(JsonValue::as_u64)?; + let height = frame.get("height").and_then(JsonValue::as_u64)?; + if width == 0 + || height == 0 + || u32::try_from(width).is_err() + || u32::try_from(height).is_err() + { + return None; + } + let mut normalized = JsonMap::from_iter([ + ( + "imageSrc".to_string(), + JsonValue::String(image_src.to_string()), + ), + ("width".to_string(), JsonValue::from(width)), + ("height".to_string(), JsonValue::from(height)), + ]); + for field in ["objectKey", "assetObjectId"] { + if let Some(value) = frame + .get(field) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + normalized.insert(field.to_string(), JsonValue::String(value.to_string())); + } + } + Some(JsonValue::Object(normalized)) + }) + .collect::>>()?; + serde_json::to_string(&normalized) + .ok() + .map(|json| (json, had_frame_index)) +} + +fn parse_migration_frames_json(value: Option<&str>) -> Option<(String, bool)> { + value + .and_then(|value| serde_json::from_str::(value).ok()) + .as_ref() + .and_then(normalize_migration_image_sequence_frames) +} + +fn merge_compatible_migration_frame_sets( + preferred_json: &str, + fallback_json: &str, +) -> Result { + let mut preferred = serde_json::from_str::>(preferred_json).map_err(|_| ())?; + let fallback = serde_json::from_str::>(fallback_json).map_err(|_| ())?; + if preferred.len() != fallback.len() { + return Err(()); + } + for (preferred_frame, fallback_frame) in preferred.iter_mut().zip(fallback) { + let preferred_frame = preferred_frame.as_object_mut().ok_or(())?; + let fallback_frame = fallback_frame.as_object().ok_or(())?; + for field in ["imageSrc", "width", "height"] { + if preferred_frame.get(field) != fallback_frame.get(field) { + return Err(()); + } + } + for field in ["objectKey", "assetObjectId"] { + match (preferred_frame.get(field), fallback_frame.get(field)) { + (Some(preferred), Some(fallback)) if preferred != fallback => return Err(()), + (None, Some(fallback)) => { + preferred_frame.insert(field.to_string(), fallback.clone()); + } + _ => {} + } + } + } + serde_json::to_string(&preferred).map_err(|_| ()) +} + +fn normalize_editor_character_animation_row( + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, + image_sequence_frames_json: Option<&str>, + image_sequence_duration_ms: Option, + evidence: EditorCharacterAnimationObjectEvidence, +) -> EditorCharacterAnimationRowPlan { + let stored_asset_kind = asset_kind.map(str::to_string); + let formal_frames = parse_migration_frames_json(image_sequence_frames_json); + let generation_value = + generation_inputs_json.and_then(|value| serde_json::from_str::(value).ok()); + let nested_runtime = generation_value + .as_ref() + .and_then(JsonValue::as_object) + .and_then(|inputs| inputs.get("characterAnimation")) + .and_then(JsonValue::as_object); + let nested_frames = nested_runtime + .and_then(|runtime| runtime.get("frames")) + .and_then(normalize_migration_image_sequence_frames); + let nested_duration = nested_runtime + .and_then(|runtime| runtime.get("durationSeconds")) + .and_then(JsonValue::as_u64) + .and_then(|seconds| seconds.checked_mul(1_000)) + .filter(|duration| *duration > 0); + let proven_before_top_level = asset_kind == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || formal_frames.is_some() + || image_sequence_duration_ms.is_some() + || nested_runtime.is_some() + || nested_frames.is_some() + || nested_duration.is_some() + || evidence.is_action_object; + let top_level_runtime = proven_before_top_level + .then_some(()) + .and_then(|_| generation_value.as_ref()) + .and_then(JsonValue::as_object); + let top_level_frames = top_level_runtime + .and_then(|runtime| runtime.get("frames")) + .and_then(normalize_migration_image_sequence_frames); + let top_level_duration = top_level_runtime + .and_then(|runtime| runtime.get("durationSeconds")) + .and_then(JsonValue::as_u64) + .and_then(|seconds| seconds.checked_mul(1_000)) + .filter(|duration| *duration > 0); + let legacy_frames = match (nested_frames, top_level_frames) { + (Some(nested), Some(top_level)) => { + let Ok(merged) = merge_compatible_migration_frame_sets(&nested.0, &top_level.0) else { + return EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind, + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: Some("嵌套与顶层旧版帧集冲突".to_string()), + }; + }; + Some((merged, nested.1 || top_level.1)) + } + (nested, top_level) => nested.or(top_level), + }; + let legacy_duration = match (nested_duration, top_level_duration) { + (Some(nested), Some(top_level)) if nested != top_level => { + return EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind, + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: Some("嵌套与顶层旧版时长冲突".to_string()), + }; + } + (nested, top_level) => nested.or(top_level), + }; + let proven_action = + proven_before_top_level || legacy_frames.is_some() || legacy_duration.is_some(); + + let mut plan = EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind.clone(), + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: None, + }; + if !proven_action { + return plan; + } + if generation_inputs_json.is_some() && generation_value.is_none() { + plan.blocker = Some("generation_inputs_json 不是合法 JSON".to_string()); + return plan; + } + + let preview_without_frames = + evidence.is_preview_video && formal_frames.is_none() && legacy_frames.is_none(); + if preview_without_frames { + plan.asset_kind = Some("video".to_string()); + plan.image_sequence_frames_json = None; + plan.image_sequence_duration_ms = None; + plan.reclassified_preview = stored_asset_kind.as_deref() != Some("video"); + } else { + let next_frames = match (formal_frames.as_ref(), legacy_frames.as_ref()) { + (Some((formal, _)), Some((legacy, _))) => { + let Ok(merged) = merge_compatible_migration_frame_sets(formal, legacy) else { + plan.blocker = Some("正式帧集与旧版帧集冲突".to_string()); + return plan; + }; + Some(merged) + } + (Some((formal, _)), None) => Some(formal.clone()), + (None, Some((legacy, _))) => { + plan.backfilled_frames = true; + Some(legacy.clone()) + } + (None, None) => { + plan.blocker = Some("角色动作缺少完整帧集".to_string()); + return plan; + } + }; + let next_duration = match (image_sequence_duration_ms, legacy_duration) { + (Some(formal), Some(legacy)) if formal != legacy => { + plan.blocker = Some("正式图片序列时长与旧版时长冲突".to_string()); + return plan; + } + (Some(formal), _) if formal > 0 => Some(formal), + (None, Some(legacy)) => { + plan.backfilled_duration = true; + Some(legacy) + } + _ => { + plan.blocker = Some("角色动作缺少有效图片序列时长".to_string()); + return plan; + } + }; + plan.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + plan.image_sequence_frames_json = next_frames; + plan.image_sequence_duration_ms = next_duration; + } + + if let Some(mut generation_value) = generation_value { + if let Some(inputs) = generation_value.as_object_mut() { + let original = inputs.clone(); + inputs.remove("characterAnimation"); + for field in [ + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] { + inputs.remove(field); + } + plan.cleaned_generation_inputs = *inputs != original; + plan.generation_inputs_json = if inputs.is_empty() { + None + } else { + serde_json::to_string(&generation_value).ok() + }; + } + } + plan.cleaned_frame_index = formal_frames.as_ref().is_some_and(|(_, value)| *value) + || legacy_frames.as_ref().is_some_and(|(_, value)| *value); + plan.changed = plan.asset_kind != stored_asset_kind + || plan.generation_inputs_json.as_deref() != generation_inputs_json + || plan.image_sequence_frames_json.as_deref() != image_sequence_frames_json + || plan.image_sequence_duration_ms != image_sequence_duration_ms; + plan +} + +fn update_normalization_stats_for_plan( + stats: &mut EditorCharacterAnimationNormalizationStats, + id: &str, + plan: &EditorCharacterAnimationRowPlan, +) { + stats.matched_count = stats.matched_count.saturating_add(u32::from(plan.changed)); + stats.backfilled_frames_count = stats + .backfilled_frames_count + .saturating_add(u32::from(plan.backfilled_frames)); + stats.backfilled_duration_count = stats + .backfilled_duration_count + .saturating_add(u32::from(plan.backfilled_duration)); + stats.reclassified_preview_count = stats + .reclassified_preview_count + .saturating_add(u32::from(plan.reclassified_preview)); + stats.cleaned_generation_inputs_count = stats + .cleaned_generation_inputs_count + .saturating_add(u32::from(plan.cleaned_generation_inputs)); + stats.cleaned_frame_index_count = stats + .cleaned_frame_index_count + .saturating_add(u32::from(plan.cleaned_frame_index)); + if plan.blocker.is_some() { + stats.blocker_ids.push(id.to_string()); + } +} + +fn update_normalization_batch_hash( + hasher: &mut Sha256, + id: &str, + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, + frames_json: Option<&str>, + duration_ms: Option, + plan: &EditorCharacterAnimationRowPlan, +) { + for value in [ + id, + asset_kind.unwrap_or_default(), + generation_inputs_json.unwrap_or_default(), + frames_json.unwrap_or_default(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher.update(duration_ms.unwrap_or_default().to_le_bytes()); + hasher.update(format!("{plan:?}").as_bytes()); +} + +fn finalize_normalization_apply_gate( + input: &EditorCharacterAnimationNormalizationInput, + stats: &mut EditorCharacterAnimationNormalizationStats, + hasher: Sha256, +) -> Result<(), String> { + stats.batch_sha256 = format!("{:x}", hasher.finalize()); + if input.dry_run { + return Ok(()); + } + if input.expected_batch_sha256.as_deref() != Some(stats.batch_sha256.as_str()) { + return Err("角色动作规范化批次 hash 已变化,拒绝 apply".to_string()); + } + Ok(()) +} + +fn normalize_editor_character_animation_metadata( + ctx: &ReducerContext, + input: EditorCharacterAnimationNormalizationInput, +) -> Result { + let scope = input.scope.trim(); + if !matches!(scope, "asset" | "project-resource" | "showcase" | "canvas") { + return Err( + "角色动作规范化 scope 只支持 asset、project-resource、showcase、canvas".to_string(), + ); + } + let maximum = if scope == "canvas" { + EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE + } else { + EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE + }; + if input.limit == 0 || input.limit > maximum { + return Err(format!("角色动作规范化 limit 必须在 1 到 {maximum} 之间")); + } + if input.dry_run && input.expected_batch_sha256.is_some() { + return Err("dry-run 不能提交 expected_batch_sha256".to_string()); + } + if !input.dry_run + && input + .expected_batch_sha256 + .as_deref() + .is_none_or(str::is_empty) + { + return Err("apply 必须提交 dry-run 返回的 expected_batch_sha256".to_string()); + } + + let stats = match scope { + "asset" => normalize_editor_character_animation_assets(ctx, &input)?, + "project-resource" => normalize_editor_character_animation_project_resources(ctx, &input)?, + "showcase" => normalize_editor_character_animation_showcases(ctx, &input)?, + "canvas" => normalize_editor_character_animation_canvases(ctx, &input)?, + _ => unreachable!(), + }; + let error_message = (!input.dry_run && !stats.blocker_ids.is_empty()).then(|| { + format!( + "角色动作规范化批次包含 {} 个 blocker,拒绝整批 apply:{}", + stats.blocker_ids.len(), + stats.blocker_ids.join(", ") + ) + }); + Ok(editor_character_animation_normalization_result( + scope.to_string(), + input.dry_run, + stats, + error_message, + )) +} + +fn editor_character_animation_normalization_result( + scope: String, + dry_run: bool, + stats: EditorCharacterAnimationNormalizationStats, + error_message: Option, +) -> EditorCharacterAnimationNormalizationProcedureResult { + EditorCharacterAnimationNormalizationProcedureResult { + ok: error_message.is_none(), + scope, + dry_run, + scanned_count: stats.scanned_count, + matched_count: stats.matched_count, + updated_count: stats.updated_count, + backfilled_frames_count: stats.backfilled_frames_count, + backfilled_duration_count: stats.backfilled_duration_count, + reclassified_preview_count: stats.reclassified_preview_count, + cleaned_generation_inputs_count: stats.cleaned_generation_inputs_count, + cleaned_frame_index_count: stats.cleaned_frame_index_count, + cleaned_canvas_layer_count: stats.cleaned_canvas_layer_count, + materialized_resource_count: stats.materialized_resource_count, + blocker_count: stats.blocker_ids.len() as u32, + blocker_ids: stats.blocker_ids, + next_cursor: stats.next_cursor, + has_more: stats.has_more, + batch_sha256: stats.batch_sha256, + error_message, + } +} + +fn failed_editor_character_animation_normalization_result( + scope: String, + dry_run: bool, + message: String, +) -> EditorCharacterAnimationNormalizationProcedureResult { + editor_character_animation_normalization_result( + scope, + dry_run, + EditorCharacterAnimationNormalizationStats::default(), + Some(message), + ) +} + +fn normalize_editor_character_animation_assets( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_asset() + .by_editor_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.asset_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.asset_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + row.object_key.as_deref(), + ); + let plan = normalize_editor_character_animation_row( + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + evidence, + ); + update_normalization_stats_for_plan(&mut stats, row.asset_id.as_str(), &plan); + update_normalization_batch_hash( + &mut hasher, + row.asset_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_asset().asset_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn normalize_editor_character_animation_project_resources( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_project_resource() + .by_editor_project_resource_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.resource_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.resource_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + row.object_key.as_deref(), + ); + let plan = normalize_editor_character_animation_row( + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + evidence, + ); + update_normalization_stats_for_plan(&mut stats, row.resource_id.as_str(), &plan); + update_normalization_batch_hash( + &mut hasher, + row.resource_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_project_resource().resource_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn normalize_editor_character_animation_showcases( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_showcase_asset() + .by_editor_showcase_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.showcase_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.showcase_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + row.object_key.as_deref(), + ); + let plan = normalize_editor_character_animation_row( + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + evidence, + ); + update_normalization_stats_for_plan(&mut stats, row.showcase_id.as_str(), &plan); + update_normalization_batch_hash( + &mut hasher, + row.showcase_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_showcase_asset().showcase_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +#[derive(Clone)] +struct EditorCharacterAnimationCanvasPlan { + canvas_layers_json: String, + project_layers_json: String, + resources: Vec, + resource_fingerprints: Vec, + cleaned_layer_ids: Vec, + blocker: Option, +} + +const EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS: [&str; 20] = [ + "src", + "imageSrc", + "thumbnailSrc", + "prompt", + "actualPrompt", + "model", + "provider", + "taskId", + "objectKey", + "assetObjectId", + "sourceResourceId", + "sourceAssetId", + "assetKind", + "generationInputs", + "mediaType", + "imageSequenceFrames", + "imageSequenceDurationMs", + "previewVideoPath", + "durationSeconds", + "sourceType", +]; + +fn remove_editor_character_animation_layout_copies( + object: &mut JsonMap, +) -> bool { + let mut changed = false; + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + changed |= object.remove(field).is_some(); + } + changed +} + +fn canonical_action_asset_for_legacy_canvas_layer( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + resource_id: &str, + item: &JsonMap, +) -> Result { + if !resource_id.starts_with("local-") { + return Err(format!( + "缺失的角色动作资源 {resource_id} 不是允许补建的 local-* ID" + )); + } + let source_asset_id = json_optional_string(item.get("sourceAssetId")); + let task_id = json_optional_string(item.get("taskId")); + let mut candidates = if let Some(source_asset_id) = source_asset_id.as_deref() { + ctx.db + .editor_asset() + .asset_id() + .find(&source_asset_id.to_string()) + .into_iter() + .collect::>() + } else { + let Some(task_id) = task_id.as_deref() else { + return Err(format!( + "本地角色动作资源 {resource_id} 缺少 sourceAssetId 和 taskId" + )); + }; + ctx.db + .editor_asset() + .by_editor_asset_owner_user_id() + .filter(&owner_user_id.to_string()) + .filter(|asset| asset.task_id.as_deref() == Some(task_id)) + .collect::>() + }; + candidates.retain(|asset| { + asset.owner_user_id == owner_user_id + && asset.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + }); + if candidates.len() != 1 { + return Err(format!( + "本地角色动作资源 {resource_id} 没有唯一匹配的账号动作素材" + )); + } + let asset = candidates.pop().expect("candidate length checked"); + let evidence = editor_character_animation_object_evidence( + ctx, + asset.asset_object_id.as_deref(), + owner_user_id, + asset.object_key.as_deref(), + ); + let normalized = normalize_editor_character_animation_row( + asset.asset_kind.as_deref(), + asset.generation_inputs_json.as_deref(), + asset.image_sequence_frames_json.as_deref(), + asset.image_sequence_duration_ms, + evidence, + ); + if normalized.blocker.is_some() || normalized.changed { + return Err(format!( + "账号动作素材 {} 尚未完成 asset scope 规范化", + asset.asset_id + )); + } + let frames_json = asset + .image_sequence_frames_json + .as_deref() + .ok_or_else(|| format!("账号动作素材 {} 缺少正式帧集", asset.asset_id))?; + let frames = serde_json::from_str::(frames_json) + .ok() + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| format!("账号动作素材 {} 正式帧集无效", asset.asset_id))?; + if let Some(layout_frames) = item.get("imageSequenceFrames") { + let (layout_frames, _) = normalize_migration_image_sequence_frames(layout_frames) + .ok_or_else(|| format!("本地角色动作资源 {resource_id} 的 layout 帧集无效"))?; + if merge_compatible_migration_frame_sets(frames_json, layout_frames.as_str()).is_err() { + return Err(format!( + "本地角色动作资源 {resource_id} 的 layout 帧集与账号素材冲突" + )); + } + } + if let Some(layout_duration_ms) = item.get("imageSequenceDurationMs") { + let layout_duration_ms = layout_duration_ms.as_u64().ok_or_else(|| { + format!("本地角色动作资源 {resource_id} 的 imageSequenceDurationMs 无效") + })?; + if Some(layout_duration_ms) != asset.image_sequence_duration_ms { + return Err(format!( + "本地角色动作资源 {resource_id} 的图片序列时长与账号素材冲突" + )); + } + } + if let Some(layout_duration_seconds) = item.get("durationSeconds") { + let layout_duration_ms = layout_duration_seconds + .as_u64() + .and_then(|seconds| seconds.checked_mul(1_000)) + .ok_or_else(|| format!("本地角色动作资源 {resource_id} 的 durationSeconds 无效"))?; + if Some(layout_duration_ms) != asset.image_sequence_duration_ms { + return Err(format!( + "本地角色动作资源 {resource_id} 的旧版时长与账号素材冲突" + )); + } + } + if let Some(layout_generation_inputs) = item.get("generationInputs") { + let layout_generation_inputs_json = serde_json::to_string(layout_generation_inputs) + .map_err(|_| format!("本地角色动作资源 {resource_id} 的生成输入无法序列化"))?; + let layout_plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(layout_generation_inputs_json.as_str()), + Some(frames_json), + asset.image_sequence_duration_ms, + EditorCharacterAnimationObjectEvidence::default(), + ); + if layout_plan.blocker.is_some() + || !json_strings_are_semantically_equal( + layout_plan.generation_inputs_json.as_deref(), + asset.generation_inputs_json.as_deref(), + ) + { + return Err(format!( + "本地角色动作资源 {resource_id} 的生成输入与账号素材冲突" + )); + } + } + let first_frame = frames + .first() + .and_then(JsonValue::as_object) + .ok_or_else(|| format!("账号动作素材 {} 缺少首帧", asset.asset_id))?; + let first_image_src = json_required_string(first_frame.get("imageSrc"), "frame.imageSrc")?; + for field in ["src", "thumbnailSrc"] { + if let Some(layout_src) = json_optional_string(item.get(field)) { + if layout_src != first_image_src { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材首帧不一致" + )); + } + } + } + if task_id + .as_deref() + .is_some_and(|value| asset.task_id.as_deref() != Some(value)) + { + return Err(format!( + "本地角色动作资源 {resource_id} 的 taskId 与账号素材不一致" + )); + } + for (field, expected) in [ + ("objectKey", asset.object_key.as_deref()), + ("assetObjectId", asset.asset_object_id.as_deref()), + ("sourceResourceId", asset.source_resource_id.as_deref()), + ] { + if let Some(value) = json_optional_string(item.get(field)) + && expected != Some(value.as_str()) + { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材冲突" + )); + } + } + for (field, expected) in [ + ("originalWidth", asset.width), + ("originalHeight", asset.height), + ] { + if let Some(value) = item.get(field).and_then(JsonValue::as_f64) { + if !value.is_finite() || value.round() as u32 != expected { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材不一致" + )); + } + } + } + Ok(EditorProjectResource { + resource_id: resource_id.to_string(), + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: asset.asset_object_id, + image_src: asset.image_src, + object_key: asset.object_key, + width: asset.width, + height: asset.height, + source_type: asset.source_type, + prompt: asset.prompt, + actual_prompt: asset.actual_prompt, + model: asset.model, + provider: asset.provider, + task_id: asset.task_id, + source_resource_id: asset.source_resource_id, + created_at: ctx.timestamp, + updated_at: ctx.timestamp, + asset_kind: asset.asset_kind, + generation_inputs_json: asset.generation_inputs_json, + public_showcase_enabled: false, + image_sequence_frames_json: asset.image_sequence_frames_json, + image_sequence_duration_ms: asset.image_sequence_duration_ms, + }) +} + +fn json_strings_are_semantically_equal(left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => { + serde_json::from_str::(left).ok() + == serde_json::from_str::(right).ok() + } + _ => false, + } +} + +fn patch_editor_character_animation_canvas_layout( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, +) -> Result<(String, Vec, Vec, Vec), String> { + let mut value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut resources = BTreeMap::::new(); + let mut resource_fingerprints = BTreeMap::::new(); + let mut cleaned_layer_ids = Vec::new(); + for item in items { + let Some(object) = item.as_object_mut() else { + return Err("图片画布布局项必须是对象".to_string()); + }; + if matches!( + object.get("itemType").and_then(JsonValue::as_str), + Some("canvas-settings" | "generation-dialog") + ) { + continue; + } + let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + let existing_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id); + let layout_marks_action = object.get("assetKind").and_then(JsonValue::as_str) + == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || object.get("mediaType").and_then(JsonValue::as_str) == Some("image-sequence"); + let resource_marks_action = existing_resource.as_ref().is_some_and(|resource| { + resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + }); + if !layout_marks_action && !resource_marks_action { + continue; + } + let resource = if let Some(resource) = existing_resource { + if resource.project_id != project_id || resource.owner_user_id != owner_user_id { + return Err(format!("角色动作资源 {resource_id} 不属于当前工程或 owner")); + } + let evidence = editor_character_animation_object_evidence( + ctx, + resource.asset_object_id.as_deref(), + owner_user_id, + resource.object_key.as_deref(), + ); + let normalized = normalize_editor_character_animation_row( + resource.asset_kind.as_deref(), + resource.generation_inputs_json.as_deref(), + resource.image_sequence_frames_json.as_deref(), + resource.image_sequence_duration_ms, + evidence, + ); + if normalized.blocker.is_some() || normalized.changed { + return Err(format!( + "角色动作资源 {resource_id} 尚未完成 project-resource scope 规范化" + )); + } + resource + } else if let Some(resource) = resources.get(&resource_id) { + resource.clone() + } else { + let resource = canonical_action_asset_for_legacy_canvas_layer( + ctx, + project_id, + owner_user_id, + resource_id.as_str(), + object, + )?; + resources.insert(resource_id.clone(), resource.clone()); + resource + }; + if resource.asset_kind.as_deref() != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + return Err(format!("画布动作图层 {resource_id} 引用了非动作资源")); + } + resource_fingerprints.insert( + resource_id.clone(), + editor_project_resource_normalization_fingerprint(&resource), + ); + let cleaned = remove_editor_character_animation_layout_copies(object); + if cleaned { + cleaned_layer_ids.push(json_required_string(object.get("layerId"), "layerId")?); + } + } + let layers_json = serde_json::to_string(&value) + .map_err(|_| "角色动作规范化后的画布布局无法序列化".to_string())?; + if layers_json.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("角色动作规范化后的画布布局超过 2 MiB".to_string()); + } + Ok(( + layers_json, + resources.into_values().collect(), + resource_fingerprints.into_values().collect(), + cleaned_layer_ids, + )) +} + +fn editor_project_resource_normalization_fingerprint(resource: &EditorProjectResource) -> String { + let mut hasher = Sha256::new(); + for value in [ + Some(resource.resource_id.as_str()), + Some(resource.project_id.as_str()), + Some(resource.owner_user_id.as_str()), + resource.asset_object_id.as_deref(), + Some(resource.image_src.as_str()), + resource.object_key.as_deref(), + Some(resource.source_type.as_str()), + resource.prompt.as_deref(), + resource.actual_prompt.as_deref(), + resource.model.as_deref(), + resource.provider.as_deref(), + resource.task_id.as_deref(), + resource.source_resource_id.as_deref(), + resource.asset_kind.as_deref(), + resource.generation_inputs_json.as_deref(), + resource.image_sequence_frames_json.as_deref(), + ] { + hasher.update(value.unwrap_or_default().as_bytes()); + hasher.update([u8::from(value.is_some())]); + hasher.update([0]); + } + hasher.update(resource.width.to_le_bytes()); + hasher.update(resource.height.to_le_bytes()); + hasher.update([u8::from(resource.public_showcase_enabled)]); + hasher.update( + resource + .image_sequence_duration_ms + .unwrap_or_default() + .to_le_bytes(), + ); + hasher.update([u8::from(resource.image_sequence_duration_ms.is_some())]); + format!("{:x}", hasher.finalize()) +} + +fn canvas_layout_may_contain_character_animation(ctx: &ReducerContext, layers_json: &str) -> bool { + let Ok(value) = serde_json::from_str::(layers_json) else { + return layers_json.contains(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || layers_json.contains("image-sequence"); + }; + let Some(items) = value.as_array() else { + return false; + }; + items.iter().any(|item| { + let Some(object) = item.as_object() else { + return false; + }; + object.get("assetKind").and_then(JsonValue::as_str) + == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || object.get("mediaType").and_then(JsonValue::as_str) == Some("image-sequence") + || object + .get("resourceId") + .and_then(JsonValue::as_str) + .and_then(|resource_id| { + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id.to_string()) + }) + .is_some_and(|resource| { + resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + }) + }) +} + +fn plan_editor_character_animation_canvas( + ctx: &ReducerContext, + canvas: &EditorCanvas, +) -> EditorCharacterAnimationCanvasPlan { + if !canvas_layout_may_contain_character_animation(ctx, canvas.layers_json.as_str()) { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: canvas.layers_json.clone(), + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: None, + }; + } + let Some(project) = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + else { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: String::new(), + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("图片画布对应的工程不存在".to_string()), + }; + }; + if project.owner_user_id != canvas.owner_user_id { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("图片画布与工程 owner 不一致".to_string()), + }; + } + if parse_legacy_layout_value(canvas.layers_json.as_str()).ok() + != parse_legacy_layout_value(project.layers_json.as_str()).ok() + { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("editor_canvas 与 editor_project 的布局不一致".to_string()), + }; + } + match patch_editor_character_animation_canvas_layout( + ctx, + canvas.layers_json.as_str(), + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + ) { + Ok((layers_json, resources, resource_fingerprints, cleaned_layer_ids)) => { + EditorCharacterAnimationCanvasPlan { + canvas_layers_json: layers_json.clone(), + project_layers_json: layers_json, + resources, + resource_fingerprints, + cleaned_layer_ids, + blocker: None, + } + } + Err(message) => EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some(message), + }, + } +} + +fn apply_editor_character_animation_canvas_plan( + ctx: &ReducerContext, + canvas: EditorCanvas, + plan: EditorCharacterAnimationCanvasPlan, +) -> Result<(), String> { + let mut project = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + .ok_or_else(|| "图片画布对应的工程不存在".to_string())?; + for resource in plan.resources { + ctx.db.editor_project_resource().try_insert(resource)?; + } + let mut structured_layout = parse_structured_canvas_layout(&plan.canvas_layers_json)?; + normalize_structured_canvas_layer_resource_metadata( + ctx, + canvas.canvas_id.as_str(), + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + &mut structured_layout, + ctx.timestamp, + true, + )?; + let next_revision = canvas + .revision + .checked_add(1) + .ok_or_else(|| "图片画布 revision 已达到上限".to_string())?; + replace_structured_canvas_layout(ctx, &canvas, &structured_layout, ctx.timestamp)?; + let mut next_canvas = canvas.clone(); + next_canvas.layers_json = plan.canvas_layers_json; + next_canvas.revision = next_revision; + next_canvas.updated_at = ctx.timestamp; + ctx.db + .editor_canvas() + .canvas_id() + .update(next_canvas.clone()); + project.layers_json = plan.project_layers_json; + project.updated_at = ctx.timestamp; + ctx.db.editor_project().project_id().update(project); + + if let Some(mut migration) = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&next_canvas.canvas_id) + { + let source_hash = canonical_layout_sha256(next_canvas.layers_json.as_str())?; + let structured_json = build_structured_canvas_layout_json_with_background( + ctx, + next_canvas.canvas_id.as_str(), + structured_layout.background_color.as_deref(), + )?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let integrity = canvas_layout_integrity(structured_json.as_str())?; + migration.source_layout_sha256 = source_hash; + migration.structured_layout_sha256 = structured_hash; + migration.verified_revision = next_revision; + migration.layer_count = integrity.layer_count; + migration.dialog_count = integrity.dialog_count; + migration.resource_refs_sha256 = integrity.resource_refs_sha256; + migration.updated_at = ctx.timestamp; + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .update(migration); + } + Ok(()) +} + +fn normalize_editor_character_animation_canvases( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_canvas() + .by_editor_canvas_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.canvas_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.canvas_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for canvas in &rows { + let plan = plan_editor_character_animation_canvas(ctx, canvas); + let changed = plan.canvas_layers_json != canvas.layers_json || !plan.resources.is_empty(); + stats.matched_count = stats.matched_count.saturating_add(u32::from(changed)); + stats.cleaned_canvas_layer_count = stats + .cleaned_canvas_layer_count + .saturating_add(plan.cleaned_layer_ids.len() as u32); + stats.materialized_resource_count = stats + .materialized_resource_count + .saturating_add(plan.resources.len() as u32); + if plan.blocker.is_some() { + stats.blocker_ids.push(canvas.canvas_id.clone()); + } + for value in [ + canvas.canvas_id.as_str(), + canvas.project_id.as_str(), + canvas.layers_json.as_str(), + plan.canvas_layers_json.as_str(), + plan.project_layers_json.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher.update(canvas.revision.to_le_bytes()); + hasher.update(format!("{:?}", plan.cleaned_layer_ids).as_bytes()); + hasher.update(plan.blocker.as_deref().unwrap_or_default().as_bytes()); + for fingerprint in &plan.resource_fingerprints { + hasher.update(fingerprint.as_bytes()); + hasher.update([0]); + } + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (canvas, plan) in rows.into_iter().zip(plans) { + if plan.canvas_layers_json == canvas.layers_json && plan.resources.is_empty() { + continue; + } + apply_editor_character_animation_canvas_plan(ctx, canvas, plan)?; + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn migration_frames(prefix: &str, with_frame_index: bool) -> JsonValue { + let mut frames = vec![ + json!({ + "imageSrc": format!("/generated/{prefix}/frame01.png"), + "objectKey": format!("generated/{prefix}/frame01.png"), + "width": 192, + "height": 256 + }), + json!({ + "imageSrc": format!("/generated/{prefix}/frame02.png"), + "assetObjectId": format!("asset-object-{prefix}-02"), + "width": 192, + "height": 256 + }), + ]; + if with_frame_index { + frames[0]["frameIndex"] = json!(99); + frames[1]["frameIndex"] = json!(1); + } + JsonValue::Array(frames) + } + + #[test] + fn character_animation_normalization_ignores_unproven_top_level_frames() { + let inputs = json!({ + "frames": migration_frames("unrelated", false), + "durationSeconds": 4, + "mediaType": "custom-input", + "purpose": "unrelated generation input" + }); + let inputs_json = inputs.to_string(); + + let plan = normalize_editor_character_animation_row( + Some("image"), + Some(inputs_json.as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert!(plan.blocker.is_none()); + assert_eq!(plan.asset_kind.as_deref(), Some("image")); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs_json.as_str()) + ); + assert!(plan.image_sequence_frames_json.is_none()); + assert!(plan.image_sequence_duration_ms.is_none()); + } + + #[test] + fn character_animation_normalization_backfills_nested_runtime_and_preserves_provenance() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "references": [{ "src": "/generated/reference.png", "mediaType": "image" }], + "screenColorHex": "#00ff00", + "characterAnimation": { + "frames": migration_frames("nested", true), + "previewVideoPath": "/generated/nested/preview.mp4", + "frameCount": 2, + "fps": 8, + "durationSeconds": 4 + } + }); + + let plan = normalize_editor_character_animation_row( + None, + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.backfilled_frames); + assert!(plan.backfilled_duration); + assert!(plan.cleaned_generation_inputs); + assert!(plan.cleaned_frame_index); + assert!(plan.blocker.is_none()); + assert_eq!( + plan.asset_kind.as_deref(), + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + ); + assert_eq!(plan.image_sequence_duration_ms, Some(4_000)); + let frames: JsonValue = serde_json::from_str( + plan.image_sequence_frames_json + .as_deref() + .expect("formal frames should be backfilled"), + ) + .expect("formal frames should parse"); + assert!( + frames + .as_array() + .is_some_and(|frames| frames.iter().all(|frame| frame.get("frameIndex").is_none())) + ); + let generation_inputs: JsonValue = serde_json::from_str( + plan.generation_inputs_json + .as_deref() + .expect("generation provenance should remain"), + ) + .expect("generation provenance should parse"); + assert_eq!(generation_inputs["fields"][0]["value"], json!("挥手")); + assert_eq!( + generation_inputs["references"][0]["mediaType"], + json!("image") + ); + assert!(generation_inputs.get("characterAnimation").is_none()); + assert!(generation_inputs.get("screenColorHex").is_none()); + } + + #[test] + fn character_animation_normalization_blocks_incomplete_nested_runtime() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "characterAnimation": { + "previewVideoPath": "/generated/nested/preview.mp4", + "frameCount": 0 + } + }); + + let plan = normalize_editor_character_animation_row( + None, + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert_eq!(plan.blocker.as_deref(), Some("角色动作缺少完整帧集")); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs.to_string().as_str()) + ); + } + + #[test] + fn character_animation_normalization_reads_top_level_runtime_only_after_action_proof() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "frames": migration_frames("top-level", false), + "durationSeconds": 3, + "fps": 8 + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.backfilled_frames); + assert!(plan.backfilled_duration); + assert_eq!(plan.image_sequence_duration_ms, Some(3_000)); + let generation_inputs: JsonValue = serde_json::from_str( + plan.generation_inputs_json + .as_deref() + .expect("fields should remain"), + ) + .expect("generation inputs should parse"); + assert_eq!( + generation_inputs, + json!({ + "fields": [{ "title": "动作", "value": "待机" }] + }) + ); + } + + #[test] + fn character_animation_normalization_blocks_formal_legacy_conflicts() { + let formal_frames = migration_frames("formal", false).to_string(); + let inputs = json!({ + "characterAnimation": { + "frames": migration_frames("legacy", false), + "durationSeconds": 9 + } + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + Some(formal_frames.as_str()), + Some(4_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert_eq!(plan.blocker.as_deref(), Some("正式帧集与旧版帧集冲突")); + assert!(!plan.changed); + } + + #[test] + fn character_animation_normalization_merges_compatible_optional_frame_references() { + let formal_frames = json!([ + { "imageSrc": "/generated/shared/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/shared/frame02.png", "width": 192, "height": 256 } + ]) + .to_string(); + let legacy_frames = json!([ + { + "imageSrc": "/generated/shared/frame01.png", + "objectKey": "generated/shared/frame01.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/shared/frame02.png", + "assetObjectId": "asset-object-shared-02", + "width": 192, + "height": 256 + } + ]); + let inputs = json!({ + "fields": [], + "characterAnimation": { + "frames": legacy_frames, + "durationSeconds": 4 + } + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + Some(formal_frames.as_str()), + Some(4_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.blocker.is_none()); + let merged: JsonValue = serde_json::from_str( + plan.image_sequence_frames_json + .as_deref() + .expect("formal frames should remain"), + ) + .expect("merged frames should parse"); + assert_eq!( + merged[0]["objectKey"], + json!("generated/shared/frame01.png") + ); + assert_eq!(merged[1]["assetObjectId"], json!("asset-object-shared-02")); + } + + #[test] + fn character_animation_normalization_reclassifies_verified_preview_video() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "screenColorHex": "#00ff00" + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence { + is_action_object: true, + is_preview_video: true, + }, + ); + + assert!(plan.changed); + assert!(plan.reclassified_preview); + assert_eq!(plan.asset_kind.as_deref(), Some("video")); + assert!(plan.image_sequence_frames_json.is_none()); + assert!(plan.image_sequence_duration_ms.is_none()); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some( + json!({ "fields": [{ "title": "动作", "value": "挥手" }] }) + .to_string() + .as_str() + ) + ); + } + + #[test] + fn character_animation_normalization_is_idempotent_for_canonical_rows() { + let frames = migration_frames("canonical", false).to_string(); + let inputs = json!({ + "fields": [{ "title": "动作", "value": "奔跑" }], + "references": [] + }) + .to_string(); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.as_str()), + Some(frames.as_str()), + Some(2_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert!(plan.blocker.is_none()); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs.as_str()) + ); + assert_eq!( + plan.image_sequence_frames_json.as_deref(), + Some(frames.as_str()) + ); + } + + #[test] + fn character_animation_canvas_cleanup_preserves_placement_and_extensions_only() { + let mut item = json!({ + "id": "layer-action-1", + "resourceId": "resource-action-1", + "x": 12, + "y": 34, + "width": 192, + "height": 256, + "rotation": 15, + "opacity": 0.8, + "customExtension": { "lockedBy": "plugin-1" } + }) + .as_object() + .expect("test layout item should be an object") + .clone(); + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + item.insert(field.to_string(), json!(format!("legacy-{field}"))); + } + + assert!(remove_editor_character_animation_layout_copies(&mut item)); + + assert_eq!(item["id"], json!("layer-action-1")); + assert_eq!(item["resourceId"], json!("resource-action-1")); + assert_eq!(item["x"], json!(12)); + assert_eq!(item["y"], json!(34)); + assert_eq!(item["width"], json!(192)); + assert_eq!(item["height"], json!(256)); + assert_eq!(item["rotation"], json!(15)); + assert_eq!(item["opacity"], json!(0.8)); + assert_eq!(item["customExtension"], json!({ "lockedBy": "plugin-1" })); + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + assert!( + !item.contains_key(field), + "legacy copy {field} should be removed" + ); + } + assert!(!remove_editor_character_animation_layout_copies(&mut item)); + } + + #[test] + fn character_animation_canvas_resource_fingerprint_ignores_timestamps_but_binds_results() { + let mut resource = + generated_editor_project_resource("resource-action-1", "task-action-1", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + resource.image_sequence_frames_json = + Some(migration_frames("canonical", false).to_string()); + resource.image_sequence_duration_ms = Some(2_000); + + let fingerprint = editor_project_resource_normalization_fingerprint(&resource); + let mut timestamp_only_change = resource.clone(); + timestamp_only_change.created_at = Timestamp::from_micros_since_unix_epoch(20); + timestamp_only_change.updated_at = Timestamp::from_micros_since_unix_epoch(30); + assert_eq!( + editor_project_resource_normalization_fingerprint(×tamp_only_change), + fingerprint + ); + + let mut duration_change = resource.clone(); + duration_change.image_sequence_duration_ms = Some(3_000); + assert_ne!( + editor_project_resource_normalization_fingerprint(&duration_change), + fingerprint + ); + + let mut frames_change = resource; + frames_change.image_sequence_frames_json = + Some(migration_frames("other", false).to_string()); + assert_ne!( + editor_project_resource_normalization_fingerprint(&frames_change), + fingerprint + ); + } + #[test] fn editor_layout_accepts_json_up_to_two_mib() { let payload = format!( @@ -8527,6 +10167,48 @@ mod tests { assert!(validate_editor_canvas_audio_asset_kind("audio").is_err()); } + #[test] + fn character_animation_storage_rejects_legacy_generation_inputs_but_leaves_other_kinds_alone() { + for field in [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] { + let mut inputs = JsonMap::from_iter([("fields".to_string(), json!([]))]); + inputs.insert(field.to_string(), json!("legacy")); + let inputs = JsonValue::Object(inputs).to_string(); + assert!( + validate_editor_generation_inputs( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.as_str()), + ) + .is_err(), + "{field} must be rejected for character actions" + ); + assert!( + validate_editor_generation_inputs(Some("image"), Some(inputs.as_str())).is_ok() + ); + } + } + + #[test] + fn character_animation_storage_rejects_frame_index() { + let frames = Some(migration_frames("write-gate", true).to_string()); + + let error = validate_editor_media_metadata( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + &frames, + Some(4_000), + ) + .expect_err("formal action frames must not contain frameIndex"); + + assert!(error.contains("无效帧")); + } + #[test] fn reusable_project_resource_backfills_missing_media_fields_and_rejects_conflicts() { let existing = EditorProjectResource { @@ -8852,146 +10534,26 @@ mod tests { ); } - fn self_contained_local_sequence_layout() -> JsonValue { - json!([{ + #[test] + fn structured_canvas_layer_without_project_resource_fails_closed() { + let layout = json!([{ "layerId": "local-sequence-layer", "resourceId": "local-resource-sequence", - "title": "历史角色动作", - "x": 10, - "y": 20, - "width": 320, - "height": 320, - "originalWidth": 512, - "originalHeight": 512, - "zIndex": 2, - "sourceType": "generated", - "mediaType": "image-sequence", - "src": null, - "imageSrc": null, - "objectKey": null, - "assetObjectId": null, - "thumbnailSrc": "/generated/sequence/thumbnail.png", - "previewVideoPath": "/generated/sequence/preview.mp4", - "imageSequenceFrames": [{ - "frameIndex": 1, - "imageSrc": "/generated/sequence/frame01.png", - "width": 512, - "height": 512 - }], - "prompt": "角色向前走", - "actualPrompt": "角色向前走,透明背景", - "model": "legacy-animation", - "provider": "legacy-provider", - "taskId": "legacy-task", - "sourceResourceId": "deleted-source-resource", "assetKind": "character-animation", - "generationInputs": { "action": "walk" } - }]) - } + "mediaType": "image-sequence", + "imageSequenceFrames": [ + { "imageSrc": "/generated/sequence/frame01.png", "width": 512, "height": 512 }, + { "imageSrc": "/generated/sequence/frame02.png", "width": 512, "height": 512 } + ], + "imageSequenceDurationMs": 1000 + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("legacy action layout should parse before resource validation"); - fn normalize_single_layer_without_resource(layout: &JsonValue) -> Result { - let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str())?; - if parsed.layers.len() != 1 { - return Err("expected one layer".to_string()); - } - let normalized = - normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], None)?; - if normalized.is_some() { - return Err("local sequence unexpectedly produced resource metadata".to_string()); - } - serialize_structured_canvas_layout(&parsed) - .map_err(|error| format!("local sequence serialization failed: {error}")) - } + let error = normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], None) + .expect_err("formal structured canvas layers must reference a project resource"); - #[test] - fn structured_canvas_self_contained_local_sequence_round_trips_and_hashes_all_metadata() { - let layout = self_contained_local_sequence_layout(); - let rebuilt = normalize_single_layer_without_resource(&layout) - .expect("valid self-contained local sequence should normalize"); - assert_eq!( - canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), - canonical_layout_sha256(rebuilt.as_str()).expect("rebuilt hash") - ); - - let mut redundant_first_frame_src = layout.clone(); - redundant_first_frame_src[0]["src"] = json!("/generated/sequence/frame01.png"); - normalize_single_layer_without_resource(&redundant_first_frame_src) - .expect("stable src equal to the first sequence frame should normalize"); - - for (pointer, changed_value) in [ - ("/0/prompt", "changed"), - ( - "/0/imageSequenceFrames/0/imageSrc", - "/generated/sequence/frame01-changed.png", - ), - ( - "/0/thumbnailSrc", - "/generated/sequence/thumbnail-changed.png", - ), - ( - "/0/previewVideoPath", - "/generated/sequence/preview-changed.mp4", - ), - ("/0/assetKind", "changed"), - ("/0/generationInputs/action", "changed"), - ] { - let mut changed = layout.clone(); - *changed - .pointer_mut(pointer) - .expect("test pointer should exist") = JsonValue::String(changed_value.to_string()); - assert_ne!( - canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), - canonical_layout_sha256(changed.to_string().as_str()).expect("changed hash"), - "{pointer} must participate in the local sequence hash" - ); - } - } - - #[test] - fn structured_canvas_self_contained_local_sequence_rejects_unstable_or_incomplete_media() { - let base = self_contained_local_sequence_layout(); - let mut invalid_layouts = Vec::new(); - for invalid_path in [ - "relative/frame.png", - "//cdn.example/frame.png", - "https://example.test/frame.png", - "/generated/frame.png?signature=secret", - "/generated/frame.png#fragment", - "data:image/png;base64,AAAA", - "blob:browser-only", - "/generated/../private/frame.png", - ] { - let mut invalid = base.clone(); - invalid[0]["imageSequenceFrames"][0]["imageSrc"] = - JsonValue::String(invalid_path.to_string()); - invalid_layouts.push(invalid); - } - for (pointer, value) in [ - ("/0/resourceId", json!("resource-not-local")), - ("/0/sourceType", json!("uploaded")), - ("/0/mediaType", json!("image")), - ("/0/imageSequenceFrames", json!([])), - ("/0/imageSequenceFrames/0/width", json!(0)), - ("/0/imageSequenceFrames/0/height", json!(-1)), - ("/0/src", json!("/generated/sequence/other.png")), - ("/0/objectKey", json!("generated/sequence/frame01.png")), - ("/0/thumbnailSrc", json!("https://example.test/thumb.png")), - ("/0/previewVideoPath", json!("/preview.mp4?expires=1")), - ] { - let mut invalid = base.clone(); - *invalid - .pointer_mut(pointer) - .expect("test pointer should exist") = value; - invalid_layouts.push(invalid); - } - let mut mismatched_frame_key = base.clone(); - mismatched_frame_key[0]["imageSequenceFrames"][0]["objectKey"] = - json!("generated/sequence/other.png"); - invalid_layouts.push(mismatched_frame_key); - for invalid in invalid_layouts { - normalize_single_layer_without_resource(&invalid) - .expect_err("invalid local sequence must fail closed"); - } + assert!(error.contains("resource")); } #[test] @@ -9015,11 +10577,11 @@ mod tests { source_resource_id: Some("source-correct".to_string()), created_at: now, updated_at: now, - asset_kind: None, + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()), generation_inputs_json: None, public_showcase_enabled: true, - image_sequence_frames_json: None, - image_sequence_duration_ms: None, + image_sequence_frames_json: Some(migration_frames("resource", false).to_string()), + image_sequence_duration_ms: Some(1_000), }; let layout = json!([{ "layerId": "layer-1", @@ -9059,16 +10621,25 @@ mod tests { .contains("sourceResourceId") ); - let mut local = parse_structured_canvas_layout( - self_contained_local_sequence_layout().to_string().as_str(), - ) - .expect("local sequence should parse"); + let local_layout = json!([{ + "layerId": "local-sequence-layer", + "resourceId": "resource-1", + "assetKind": "character-animation", + "mediaType": "image-sequence", + "imageSequenceFrames": [ + { "imageSrc": "/generated/sequence/frame01.png", "width": 512, "height": 512 }, + { "imageSrc": "/generated/sequence/frame02.png", "width": 512, "height": 512 } + ], + "imageSequenceDurationMs": 1000 + }]); + let mut local = parse_structured_canvas_layout(local_layout.to_string().as_str()) + .expect("legacy action layout should parse"); let error = normalize_structured_canvas_layer_against_resource( &mut local.layers[0], Some(&resource), ) - .expect_err("local sequence with a resource row must be ambiguous"); - assert!(error.contains("权威元数据")); + .expect_err("layout action copies must not override resource truth"); + assert!(!error.is_empty()); let resource_without_source = EditorProjectResource { source_resource_id: None, @@ -9084,56 +10655,6 @@ mod tests { .expect("legacy self-reference should also be ignored when resource truth is empty"); } - #[test] - fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() { - let layout = self_contained_local_sequence_layout(); - let parsed = parse_structured_canvas_layout(layout.to_string().as_str()) - .expect("local sequence should parse"); - let existing_source_type = parsed.layers[0].source_type.clone(); - let existing_item_json = parsed.layers[0].item_json.clone(); - - let mut moved = parsed.layers[0].clone(); - moved.x = Some(999.0); - moved.y = Some(888.0); - assert!(legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &moved, - )); - - let mut omitted_frontend_metadata = moved.clone(); - let mut item = serde_json::from_str::>( - omitted_frontend_metadata.item_json.as_str(), - ) - .expect("local item extension should parse"); - item.remove("assetKind"); - item.remove("generationInputs"); - omitted_frontend_metadata.item_json = - serde_json::to_string(&item).expect("item should serialize"); - restore_omitted_legacy_local_sequence_metadata( - existing_item_json.as_str(), - &mut omitted_frontend_metadata, - ) - .expect("frontend-omitted frozen metadata should restore"); - assert!(legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &omitted_frontend_metadata, - )); - - let mut changed_media = moved; - let mut item = - serde_json::from_str::>(changed_media.item_json.as_str()) - .expect("local item extension should parse"); - item.insert("prompt".to_string(), json!("篡改后的提示词")); - changed_media.item_json = serde_json::to_string(&item).expect("item should serialize"); - assert!(!legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &changed_media, - )); - } - fn release_fixture_option_string(value: &JsonValue) -> Result, ()> { let value = value.as_array().ok_or(())?; if value.len() != 2 { @@ -9206,11 +10727,6 @@ mod tests { .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS is required") .parse::() .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS must be an integer"); - let expected_local_sequences = std::env::var("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES") - .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES is required") - .parse::() - .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES must be an integer"); - let mut layout_paths = fs::read_dir(&fixture_dir) .expect("fixture directory should be readable") .filter_map(Result::ok) @@ -9230,7 +10746,6 @@ mod tests { let mut ok_count = 0usize; let mut conflict_count = 0usize; - let mut local_sequence_count = 0usize; for layout_path in layout_paths { let sample_name = layout_path .file_name() @@ -9267,20 +10782,6 @@ mod tests { "fixture {sample_name} raw integrity drifted" ); - for layer in &layout.layers { - let item = serde_json::from_str::>(&layer.item_json) - .unwrap_or_else(|_| panic!("fixture {sample_name} item extension is invalid")); - match validate_self_contained_legacy_local_image_sequence( - layer.resource_id.as_str(), - layer.source_type.as_deref(), - &item, - ) { - Ok(true) => local_sequence_count += 1, - Ok(false) => {} - Err(_) => panic!("fixture {sample_name} local sequence validation failed"), - } - } - let resources = fs::read_to_string(resources_path) .unwrap_or_else(|_| panic!("fixture {sample_name} resources are unreadable")); let resources = serde_json::from_str::>(&resources) @@ -9368,7 +10869,6 @@ mod tests { assert_eq!(ok_count, expected_ok); assert_eq!(conflict_count, expected_conflicts); - assert_eq!(local_sequence_count, expected_local_sequences); } #[test] -- 2.52.0 From 8a4ba6283b705667933290b8ed120da7f32d3e4b 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:20:24 +0800 Subject: [PATCH 03/42] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=90=8E=E7=AB=AF=E6=AD=A3=E5=BC=8F=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色动作生成只持久化并返回正式 project resource 与 account asset。 删除 API、后台 mapper 对 generationInputs 和布局动作结果的运行时回退。 动作 generationInputs 旧运行字段、screenColorHex 与正式帧 frameIndex 明确拒绝。 动作画布完成项只写资源引用与 placement,普通 layer 响应移除顶层 mediaType。 --- server-rs/crates/api-server/src/admin.rs | 108 ++- .../src/character_animation_assets.rs | 223 ++--- .../crates/api-server/src/editor_project.rs | 771 ++++++++++++++++-- .../api-server/src/external_editor_api.rs | 48 +- .../generation.rs | 10 +- 5 files changed, 919 insertions(+), 241 deletions(-) diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 66e21f696..74b96f905 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -77,7 +77,10 @@ use crate::{ create_direct_upload_ticket_for_owner, get_asset_read_url_with_query, }, editor_generation_config::EditorGenerationPricingConfig, - editor_project::current_utc_micros, + editor_project::{ + current_utc_micros, normalize_editor_image_sequence_frames_value, + resolve_editor_asset_kind, sanitize_editor_generation_inputs, + }, http_error::AppError, request_context::RequestContext, state::{AdminRuntime, AppState}, @@ -1056,6 +1059,15 @@ fn admin_editor_showcase_asset_payload_from_record( record: EditorShowcaseAssetRecord, ) -> AdminEditorShowcaseAssetPayload { let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None); + let formal_image_sequence_frames = record + .image_sequence_frames + .as_ref() + .and_then(normalize_editor_image_sequence_frames_value); + let asset_kind = resolve_editor_asset_kind( + record.asset_kind, + record.image_src.as_str(), + record.object_key.as_deref(), + ); AdminEditorShowcaseAssetPayload { showcase_id: record.showcase_id, asset_id: record.asset_id, @@ -1072,8 +1084,8 @@ fn admin_editor_showcase_asset_payload_from_record( model: record.model, provider: record.provider, task_id: record.task_id, - asset_kind: record.asset_kind, - generation_inputs: record.generation_inputs, + asset_kind, + generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, refund_mud_points: record.refund_mud_points, @@ -1091,6 +1103,8 @@ fn admin_editor_showcase_asset_payload_from_record( rejected_at: record.rejected_at, updated_at: record.updated_at, showcase_category: record.showcase_category, + image_sequence_frames: formal_image_sequence_frames, + image_sequence_duration_ms: record.image_sequence_duration_ms, } } @@ -1193,6 +1207,15 @@ fn admin_editor_asset_payload_from_record( ) -> AdminEditorAssetPayload { let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None); let generator = resolve_admin_editor_asset_generator(&record); + let formal_image_sequence_frames = record + .image_sequence_frames + .as_ref() + .and_then(normalize_editor_image_sequence_frames_value); + let asset_kind = resolve_editor_asset_kind( + record.asset_kind, + record.image_src.as_str(), + record.object_key.as_deref(), + ); AdminEditorAssetPayload { asset_id: record.asset_id, owner_user_id: record.owner_user_id, @@ -1212,8 +1235,8 @@ fn admin_editor_asset_payload_from_record( provider: record.provider, task_id: record.task_id, group_task_id: record.group_task_id, - asset_kind: record.asset_kind, - generation_inputs: record.generation_inputs, + asset_kind, + generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), source_resource_id: record.source_resource_id, thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, @@ -1223,6 +1246,8 @@ fn admin_editor_asset_payload_from_record( task_cost_mud_points: record.generation_cost_mud_points, generator, children: Vec::new(), + image_sequence_frames: formal_image_sequence_frames, + image_sequence_duration_ms: record.image_sequence_duration_ms, } } @@ -4043,10 +4068,11 @@ mod tests { use super::{ AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, - admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record, - append_spacetime_sql_response_chunk, apply_admin_database_table_filters, - build_admin_asset_read_url_audit, build_admin_dashboard_chart, - build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, + admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, + admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, + apply_admin_database_table_filters, build_admin_asset_read_url_audit, + build_admin_dashboard_chart, build_admin_database_table_row, + build_admin_editor_showcase_campaign_image_confirm_request, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, @@ -4117,6 +4143,8 @@ mod tests { rejected_at: None, updated_at: "2026-07-04T10:00:00.000Z".to_string(), showcase_category: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, } } @@ -4151,6 +4179,8 @@ mod tests { generation_cost_mud_points, created_at: "2026-07-04T10:00:00Z".to_string(), updated_at: "2026-07-04T10:00:00Z".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, } } @@ -4279,6 +4309,66 @@ mod tests { ); } + #[test] + fn admin_editor_showcase_payload_uses_formal_action_fields_only() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let formal_frames = json!([ + { + "imageSrc": "/generated/action/formal-01.png", + "objectKey": "generated/action/formal-01.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/action/formal-02.png", + "objectKey": "generated/action/formal-02.png", + "width": 192, + "height": 256 + } + ]); + let mut record = test_admin_editor_showcase_asset_record("user-author-1"); + record.asset_kind = Some("character-animation".to_string()); + record.image_sequence_frames = Some(formal_frames.clone()); + record.image_sequence_duration_ms = Some(6_000); + record.generation_inputs = Some(json!({ + "fields": [{ "title": "动作", "value": "挥手" }] + })); + + let payload = admin_editor_showcase_asset_payload_from_record(&state, record); + + assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); + assert_eq!(payload.image_sequence_frames, Some(formal_frames)); + assert_eq!(payload.image_sequence_duration_ms, Some(6_000)); + assert_eq!( + payload.generation_inputs, + Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })) + ); + } + + #[test] + fn admin_editor_asset_payload_preserves_formal_character_action_metadata() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let frames = json!([ + { "imageSrc": "/generated/action/frame-01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/action/frame-02.png", "width": 192, "height": 256 } + ]); + let mut record = test_admin_editor_asset_record( + "action-asset", + "action-task", + "角色动作", + "generated/action/frame-01.png", + 12, + ); + record.asset_kind = Some("character-animation".to_string()); + record.image_sequence_frames = Some(frames.clone()); + record.image_sequence_duration_ms = Some(5_000); + + let payload = admin_editor_asset_payload_from_record(&state, record); + + assert_eq!(payload.image_sequence_frames, Some(frames)); + assert_eq!(payload.image_sequence_duration_ms, Some(5_000)); + } + #[test] fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() { let final_output = test_admin_editor_asset_record( diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index de5452fa0..f570901d8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -70,9 +70,8 @@ use crate::{ EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX, EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, - PersistEditorGeneratedAssetRequest, - apply_editor_screen_background_decision_to_generation_inputs, - build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, + PersistEditorGeneratedAssetRequest, build_editor_canvas_generated_layer_item, + build_editor_canvas_resource_placement_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, remove_editor_generated_screen_background_with_bgfilter, resolve_editor_reference_object_key_for_owner, @@ -117,6 +116,7 @@ const SOURCE_IMAGE_RESOLVE_TIMEOUT_MS: u64 = 30_000; const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast"; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation"; +const EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND: &str = "video"; const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source"; const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; @@ -739,11 +739,6 @@ pub(crate) async fn generate_editor_character_animation_for_owner( }), ) .await?; - // 把实际选定的背景色落进资产记录,供后续以该资产为参考图时反查旧背景色。 - let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - generation_inputs, - Some(&screen_background_decision), - ); // 用决策色重新归一化以拿到最终提示词(价格 / 校验已在预扣前用占位色完成)。 let normalized = normalize_editor_character_animation_request_with_pricing( payload, @@ -792,11 +787,13 @@ pub(crate) async fn generate_editor_character_animation_for_owner( task_id: task_id.clone(), source_resource_id: source_resource_id.clone(), asset_kind: Some( - EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND.to_string(), ), generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(normalized.price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -819,23 +816,22 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) .await?; let frames = persisted_frames.frames; - let generation_inputs = attach_editor_character_animation_result_metadata( - generation_inputs, - generated.preview_video_path.as_str(), - frames.as_slice(), - normalized.fps, - normalized.duration_seconds, - ); let first_frame = frames.first().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-character-animation", "message": "角色动作抽帧完成但没有可保存的序列帧。", })) })?; - persist_editor_generated_media_asset( + let frame_payload_json = serde_json::to_value(&frames).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": format!("角色动作序列帧无法持久化:{error}"), + })) + })?; + let (final_resource, final_asset) = persist_editor_generated_media_asset( &state, PersistEditorGeneratedAssetRequest { - project_id: None, + project_id: project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id .clone() @@ -851,70 +847,72 @@ pub(crate) async fn generate_editor_character_animation_for_owner( model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), provider: "Ark".to_string(), task_id: task_id.clone(), - source_resource_id: provider_source_resource_id, + source_resource_id: provider_source_resource_id.clone(), asset_kind: Some( EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), ), generation_inputs: generation_inputs.clone(), thumbnail_src: Some(first_frame.image_src.clone()), generation_cost_mud_points: 0, + image_sequence_frames: Some(frame_payload_json), + image_sequence_duration_ms: Some( + u64::from(normalized.duration_seconds) * 1_000, + ), }, ) .await?; + let final_asset = final_asset.ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作透明序列帧已生成,但最终素材记录缺失。", + })) + })?; + let final_asset = serde_json::to_value(final_asset).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": format!("角色动作最终素材响应无法序列化:{error}"), + })) + })?; - Ok::<_, AppError>((generated, frames, normalized, generation_inputs)) + Ok::<_, AppError>(( + generated, + frames, + normalized, + final_resource, + final_asset, + )) }, ) .await; - let (generated, frames, normalized, generation_inputs) = + let (generated, frames, normalized, final_resource, final_asset) = result.map_err(|error| character_animation_error_response(&request_context, error))?; let completed_project = if let Some(completion) = canvas_completion.as_ref() { - let frame_payload_json = serde_json::to_value(&frames).map_err(|error| { + if project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request("角色动作画布完成需要有效的 projectId。"), + )); + } + let final_resource = final_resource.as_ref().ok_or_else(|| { character_animation_error_response( &request_context, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-character-animation", - "message": format!("角色动作序列帧无法写入画布:{error}"), + "message": "角色动作画布完成缺少最终项目资源。", })), ) })?; - let preview_frame = frames.first(); let layer_id = format!("layer-editor-character-animation-{task_id}"); - let item = build_editor_canvas_generated_layer_item( + let item = build_editor_canvas_resource_placement_item( completion, - EditorCanvasGeneratedLayerInput { - layer_id: layer_id.clone(), - resource_id: format!("local-resource-editor-character-animation-{task_id}"), - title: completion.title.trim().to_string(), - src: preview_frame - .map(|frame| frame.image_src.clone()) - .unwrap_or_else(|| generated.preview_video_path.clone()), - media_type: Some("image-sequence".to_string()), - thumbnail_src: preview_frame.map(|frame| frame.image_src.clone()), - original_width: preview_frame - .map(|frame| frame.width) - .unwrap_or(normalized.frame_width), - original_height: preview_frame - .map(|frame| frame.height) - .unwrap_or(normalized.frame_height), - display_width: completion.placeholder.width, - display_height: completion.placeholder.height, - source_type: "generated".to_string(), - prompt: Some(generated.submitted_prompt.clone()), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()), - provider: Some("ark".to_string()), - task_id: Some(task_id.clone()), - object_key: None, - asset_object_id: None, - duration_seconds: Some(normalized.duration_seconds), - source_resource_id, - asset_kind: Some("character-animation".to_string()), - generation_inputs, - image_sequence_frames: Some(frame_payload_json), - preview_video_path: Some(generated.preview_video_path.clone()), - }, + final_resource, + layer_id.clone(), ); complete_editor_canvas_generation_with_items( &state, @@ -931,6 +929,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( }; let completed_project = completed_project.and_then(|project| serde_json::to_value(project).ok()); + let final_resource = final_resource.and_then(|resource| serde_json::to_value(resource).ok()); Ok(json_success_body( Some(&request_context), @@ -948,6 +947,8 @@ pub(crate) async fn generate_editor_character_animation_for_owner( price_mud_points: normalized.price_mud_points, frames, project: completed_project, + resource: final_resource, + asset: Some(final_asset), queue_state: None, }, )) @@ -1093,6 +1094,8 @@ pub(crate) async fn generate_editor_video_for_owner( generation_inputs: generation_inputs.clone(), thumbnail_src: generated.thumbnail_src.clone(), generation_cost_mud_points: u64::from(normalized.price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await @@ -1110,7 +1113,6 @@ pub(crate) async fn generate_editor_video_for_owner( resource_id, title: completion.title.trim().to_string(), src: image_src.clone(), - media_type: Some("video".to_string()), thumbnail_src: generated.thumbnail_src.clone(), original_width: normalized.width, original_height: normalized.height, @@ -1128,8 +1130,6 @@ pub(crate) async fn generate_editor_video_for_owner( source_resource_id, asset_kind: Some(asset_kind.clone()), generation_inputs, - image_sequence_frames: None, - preview_video_path: None, }, ); complete_editor_canvas_generation_with_items( @@ -2359,7 +2359,7 @@ async fn extract_and_persist_editor_character_animation_frames( Err(frame_error) => frame_errors.push(frame_error), } } - frame_payloads.sort_by_key(|frame| frame.payload.frame_index); + frame_payloads.sort_by_key(|frame| frame.frame_index); frame_errors.sort_by_key(|(frame_index, _)| *frame_index); if let Some((frame_index, error)) = frame_errors.into_iter().next() { tracing::warn!( @@ -2491,8 +2491,8 @@ async fn process_and_persist_editor_character_animation_frame( .await?; Ok(ProcessedEditorCharacterAnimationFrame { + frame_index, payload: EditorCharacterAnimationFramePayload { - frame_index: frame_index as u32 + 1, image_src: put_result.legacy_public_path, width: frame_width, height: frame_height, @@ -5138,31 +5138,6 @@ fn editor_character_animation_source_asset_label(asset_label: &str) -> String { format!("{base}{suffix}") } -fn attach_editor_character_animation_result_metadata( - generation_inputs: Option, - preview_video_path: &str, - frames: &[EditorCharacterAnimationFramePayload], - fps: u32, - duration_seconds: u32, -) -> Option { - let mut object = match generation_inputs { - Some(Value::Object(object)) => object, - Some(value) => serde_json::Map::from_iter([("sourceGenerationInputs".to_string(), value)]), - None => serde_json::Map::new(), - }; - object.insert( - "characterAnimation".to_string(), - json!({ - "previewVideoPath": preview_video_path, - "frames": frames, - "frameCount": frames.len(), - "fps": fps, - "durationSeconds": duration_seconds, - }), - ); - Some(Value::Object(object)) -} - fn clamp_prompt_seed_text(value: Option<&str>) -> String { trim_optional_text(value) .unwrap_or_default() @@ -5951,6 +5926,7 @@ struct PersistedEditorCharacterAnimationFrameSet { } struct ProcessedEditorCharacterAnimationFrame { + frame_index: usize, payload: EditorCharacterAnimationFramePayload, object_key: String, asset_object_id: String, @@ -6409,7 +6385,8 @@ mod tests { "persist_editor_generated_media_asset", "reporter.report_processing(&state).await?", "extract_and_persist_editor_character_animation_frames", - "Ok::<_, AppError>((generated, frames, normalized, generation_inputs))", + "Ok::<_, AppError>((", + "final_asset", ], ); } @@ -6608,49 +6585,6 @@ mod tests { assert!(!frame_pipeline.contains("DownloadedOpenAiImage")); } - #[test] - fn editor_character_animation_result_metadata_keeps_the_whole_frame_set() { - let frames = vec![ - EditorCharacterAnimationFramePayload { - frame_index: 1, - image_src: "/generated-animations/editor/layer/task/frame01.png".to_string(), - width: 192, - height: 256, - }, - EditorCharacterAnimationFramePayload { - frame_index: 2, - image_src: "/generated-animations/editor/layer/task/frame02.png".to_string(), - width: 192, - height: 256, - }, - ]; - - let metadata = attach_editor_character_animation_result_metadata( - Some(json!({ - "fields": [{ "title": "动作", "value": "待机" }], - "references": [], - })), - "/generated-character-drafts/editor/layer/task/preview.mp4", - frames.as_slice(), - 8, - 4, - ) - .expect("character animation metadata should exist"); - - assert_eq!(metadata["fields"][0]["value"], "待机"); - assert_eq!(metadata["characterAnimation"]["frameCount"], 2); - assert_eq!(metadata["characterAnimation"]["fps"], 8); - assert_eq!(metadata["characterAnimation"]["durationSeconds"], 4); - assert_eq!( - metadata["characterAnimation"]["previewVideoPath"], - "/generated-character-drafts/editor/layer/task/preview.mp4" - ); - assert_eq!( - metadata["characterAnimation"]["frames"][1]["imageSrc"], - "/generated-animations/editor/layer/task/frame02.png" - ); - } - #[test] fn editor_character_animation_persists_one_final_asset_after_frame_extraction() { let source = include_str!("character_animation_assets.rs"); @@ -6660,11 +6594,16 @@ mod tests { "pub async fn generate_editor_video", &[ "let persisted_frames = extract_and_persist_editor_character_animation_frames", - "attach_editor_character_animation_result_metadata", - "project_id: None", + "let frame_payload_json = serde_json::to_value(&frames)", + "let (final_resource, final_asset)", + "project_id: project_id.clone()", "label: asset_label.clone()", "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", "generation_cost_mud_points: 0", + "image_sequence_frames: Some(frame_payload_json)", + "image_sequence_duration_ms: Some(", + "resource: final_resource", + "asset: Some(final_asset)", ], ); assert_function_contains( @@ -6687,6 +6626,22 @@ mod tests { ); } + #[test] + fn editor_character_animation_canvas_completion_uses_the_final_resource_lineage() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "let (final_resource, final_asset)", + "source_resource_id: provider_source_resource_id.clone()", + "provider_source_resource_id,", + "source_resource_id: final_source_resource_id", + ], + ); + } + #[test] fn editor_video_intermediate_outputs_are_registered_before_derivatives() { let source = include_str!("character_animation_assets.rs"); @@ -6706,7 +6661,7 @@ mod tests { "pub async fn generate_editor_video", &[ "or_else(|| Some(\"project\".to_string()))", - "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", + "EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND", "generation_cost_mud_points: u64::from(normalized.price_mud_points)", ], ); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index eb93008d9..a30623653 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -112,6 +112,7 @@ const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; +const EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND: &str = "character-animation"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; @@ -205,6 +206,8 @@ pub struct EditorProjectResourceCreateRequest { source_resource_id: Option, asset_kind: Option, generation_inputs: Option, + image_sequence_frames: Option, + image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] @@ -245,6 +248,8 @@ pub struct EditorAssetCreateRequest { task_id: Option, asset_kind: Option, generation_inputs: Option, + image_sequence_frames: Option, + image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] @@ -585,6 +590,8 @@ pub(crate) struct PersistEditorGeneratedAssetInput { pub(crate) generation_inputs: Option, pub(crate) thumbnail_src: Option, pub(crate) generation_cost_mud_points: u64, + pub(crate) image_sequence_frames: Option, + pub(crate) image_sequence_duration_ms: Option, } pub(crate) struct PersistEditorGeneratedAssetRequest { @@ -607,6 +614,8 @@ pub(crate) struct PersistEditorGeneratedAssetRequest { pub(crate) generation_inputs: Option, pub(crate) thumbnail_src: Option, pub(crate) generation_cost_mud_points: u64, + pub(crate) image_sequence_frames: Option, + pub(crate) image_sequence_duration_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -628,7 +637,6 @@ pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) resource_id: String, pub(crate) title: String, pub(crate) src: String, - pub(crate) media_type: Option, pub(crate) thumbnail_src: Option, pub(crate) original_width: u32, pub(crate) original_height: u32, @@ -646,8 +654,6 @@ pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, - pub(crate) image_sequence_frames: Option, - pub(crate) preview_video_path: Option, } #[derive(Debug, Serialize)] @@ -901,6 +907,10 @@ pub struct EditorProjectResourcePayload { #[serde(skip_serializing_if = "Option::is_none")] showcase_category: Option, generation_inputs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image_sequence_frames: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image_sequence_duration_ms: Option, public_showcase_enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] review_status: Option, @@ -967,6 +977,10 @@ pub struct EditorAssetPayload { provider: Option, task_id: Option, asset_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image_sequence_frames: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image_sequence_duration_ms: Option, generation_inputs: Option, source_resource_id: Option, public_showcase_enabled: Option, @@ -1253,8 +1267,12 @@ pub async fn create_editor_project_resource( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - let generation_inputs_json = - serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let generation_inputs_json = serialize_editor_generation_inputs( + payload.asset_kind.as_deref(), + payload.generation_inputs.clone(), + )?; + let image_sequence_frames_json = + serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state @@ -1278,6 +1296,8 @@ pub async fn create_editor_project_resource( asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, updated_at_micros: current_utc_micros(), + image_sequence_frames_json, + image_sequence_duration_ms: payload.image_sequence_duration_ms, }) .await .map_err(map_editor_project_error)?; @@ -1421,8 +1441,12 @@ pub async fn create_editor_asset( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - let generation_inputs_json = - serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let generation_inputs_json = serialize_editor_generation_inputs( + payload.asset_kind.as_deref(), + payload.generation_inputs.clone(), + )?; + let image_sequence_frames_json = + serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state @@ -1451,6 +1475,8 @@ pub async fn create_editor_asset( generation_cost_mud_points: 0, group_task_id: None, group_task_expected_asset_count: None, + image_sequence_frames_json, + image_sequence_duration_ms: payload.image_sequence_duration_ms, }) .await .map_err(map_editor_project_error)?; @@ -2195,6 +2221,8 @@ pub(crate) async fn generate_editor_image_for_owner( } else { u64::from(configured_price_mud_points) }, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -2266,8 +2294,8 @@ pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs( Some(other) => return Some(other), None => json!({ "fields": [], "references": [] }), }; - // 抠图背景色是内部自动决策 / 兜底细节,产品契约要求不进用户可见的 generationInputs.fields; - // 只写顶层内部可追踪元数据 screenColorHex(不属于用户输入快照,前端展示不读取)。 + // 抠图背景色是内部自动决策 / 兜底细节,不进入用户可见的 generationInputs.fields; + // 顶层 screenColorHex 用于后续以该资产为参考图时复用本次实际选定的键色。 value["screenColorHex"] = Value::String(decision.color.hex.to_string()); Some(value) } @@ -3810,6 +3838,8 @@ pub(crate) async fn edit_editor_image_for_owner( generation_inputs: payload.generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -3966,6 +3996,8 @@ pub(crate) async fn remove_editor_image_background_for_owner( generation_inputs: payload.generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -4431,6 +4463,8 @@ async fn persist_editor_spritesheet_slices( generation_inputs: input.generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: 0, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -4921,6 +4955,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -5817,6 +5853,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await?; @@ -6207,6 +6245,15 @@ pub(crate) fn editor_project_resource_payload_from_record( ) -> EditorProjectResourcePayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let formal_image_sequence_frames = record + .image_sequence_frames + .as_ref() + .and_then(normalize_editor_image_sequence_frames_value); + let asset_kind = resolve_editor_asset_kind( + record.asset_kind, + image_src.as_str(), + record.object_key.as_deref(), + ); EditorProjectResourcePayload { resource_id: record.resource_id, showcase_id: None, @@ -6228,11 +6275,11 @@ pub(crate) fn editor_project_resource_payload_from_record( provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, - asset_kind: record.asset_kind, + asset_kind, showcase_category: None, - generation_inputs: record - .generation_inputs - .map(sanitize_editor_payload_inline_media), + generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), + image_sequence_frames: formal_image_sequence_frames, + image_sequence_duration_ms: record.image_sequence_duration_ms, public_showcase_enabled: record.public_showcase_enabled, review_status: None, display_enabled: None, @@ -6249,6 +6296,15 @@ fn editor_project_resource_payload_from_showcase_record( ) -> EditorProjectResourcePayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let formal_image_sequence_frames = record + .image_sequence_frames + .as_ref() + .and_then(normalize_editor_image_sequence_frames_value); + let asset_kind = resolve_editor_asset_kind( + record.asset_kind, + image_src.as_str(), + record.object_key.as_deref(), + ); EditorProjectResourcePayload { resource_id: record.showcase_id.clone(), showcase_id: Some(record.showcase_id), @@ -6270,11 +6326,11 @@ fn editor_project_resource_payload_from_showcase_record( provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, - asset_kind: record.asset_kind, + asset_kind, showcase_category: record.showcase_category, - generation_inputs: record - .generation_inputs - .map(sanitize_editor_payload_inline_media), + generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), + image_sequence_frames: formal_image_sequence_frames, + image_sequence_duration_ms: record.image_sequence_duration_ms, public_showcase_enabled: record.display_enabled, review_status: Some(record.review_status), display_enabled: Some(record.display_enabled), @@ -6352,6 +6408,15 @@ pub(crate) fn editor_asset_folder_payload_from_record( pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let formal_image_sequence_frames = record + .image_sequence_frames + .as_ref() + .and_then(normalize_editor_image_sequence_frames_value); + let asset_kind = resolve_editor_asset_kind( + record.asset_kind, + image_src.as_str(), + record.object_key.as_deref(), + ); EditorAssetPayload { asset_id: record.asset_id, folder_id: record.folder_id, @@ -6367,10 +6432,10 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi model: record.model, provider: record.provider, task_id: record.task_id, - asset_kind: record.asset_kind, - generation_inputs: record - .generation_inputs - .map(sanitize_editor_payload_inline_media), + asset_kind, + image_sequence_frames: formal_image_sequence_frames, + image_sequence_duration_ms: record.image_sequence_duration_ms, + generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), source_resource_id: record.source_resource_id, public_showcase_enabled: record.public_showcase_enabled, thumbnail_src: normalize_optional_string(record.thumbnail_src), @@ -6384,6 +6449,103 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi } } +pub(crate) fn resolve_editor_asset_kind( + asset_kind: Option, + image_src: &str, + object_key: Option<&str>, +) -> Option { + if asset_kind.is_some() { + return asset_kind; + } + let source_is_video = + editor_asset_source_has_extension(image_src, object_key, &["mp4", "mov", "webm", "avi"]); + let source_is_audio = editor_asset_source_has_extension( + image_src, + object_key, + &["mp3", "wav", "ogg", "m4a", "aac", "flac"], + ); + if source_is_video { + Some("video".to_string()) + } else if source_is_audio { + Some("audio".to_string()) + } else { + Some("image".to_string()) + } +} + +pub(crate) fn sanitize_editor_generation_inputs(value: Option) -> Option { + value + .map(sanitize_editor_payload_inline_media) + .filter(|value| { + value + .as_object() + .map(|inputs| !inputs.is_empty()) + .unwrap_or(true) + }) +} + +fn normalize_editor_image_sequence_frame(frame: &Value) -> Option { + let Some(frame) = frame.as_object() else { + return None; + }; + if frame.contains_key("frameIndex") { + return None; + } + let image_src = frame + .get("imageSrc") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let width = frame.get("width").and_then(Value::as_u64)?; + let height = frame.get("height").and_then(Value::as_u64)?; + if width == 0 || height == 0 || u32::try_from(width).is_err() || u32::try_from(height).is_err() + { + return None; + } + let mut normalized = serde_json::Map::from_iter([ + ("imageSrc".to_string(), Value::String(image_src.to_string())), + ("width".to_string(), Value::from(width)), + ("height".to_string(), Value::from(height)), + ]); + for field in ["objectKey", "assetObjectId"] { + if let Some(value) = frame + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + normalized.insert(field.to_string(), Value::String(value.to_string())); + } + } + Some(Value::Object(normalized)) +} + +pub(crate) fn normalize_editor_image_sequence_frames_value(frames: &Value) -> Option { + let frames = frames.as_array()?; + let normalized = frames + .iter() + .map(normalize_editor_image_sequence_frame) + .collect::>>()?; + (normalized.len() >= 2).then_some(Value::Array(normalized)) +} + +fn editor_asset_source_has_extension( + image_src: &str, + object_key: Option<&str>, + extensions: &[&str], +) -> bool { + [Some(image_src), object_key] + .into_iter() + .flatten() + .filter_map(|value| value.split(['?', '#']).next()) + .filter_map(|value| value.rsplit('.').next()) + .any(|extension| { + extensions + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} + fn editor_showcase_campaign_payload_from_record( record: EditorShowcaseCampaignConfigRecord, ) -> EditorShowcaseCampaignPayload { @@ -6428,10 +6590,29 @@ fn sanitize_editor_payload_media( ) }) .collect(); + strip_legacy_canvas_layer_media_type(&mut value); sanitize_editor_payload_media_value(&mut value, None, &resource_media); value } +fn strip_legacy_canvas_layer_media_type(value: &mut Value) { + let Some(items) = value.as_array_mut() else { + return; + }; + for item in items { + let Some(object) = item.as_object_mut() else { + continue; + }; + if matches!( + object.get("itemType").and_then(Value::as_str), + Some("canvas-settings" | "generation-dialog") + ) { + continue; + } + object.remove("mediaType"); + } +} + fn sanitize_editor_payload_inline_media(mut value: Value) -> Value { sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new()); value @@ -6529,7 +6710,12 @@ pub(crate) async fn persist_editor_generated_asset( state: &AppState, input: PersistEditorGeneratedAssetInput, ) -> Result { - let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; + let generation_inputs_json = serialize_editor_generation_inputs( + input.asset_kind.as_deref(), + input.generation_inputs.clone(), + )?; + let image_sequence_frames_json = + serialize_editor_image_sequence_frames(input.image_sequence_frames.clone())?; let object_key = normalize_editor_object_key(input.object_key); let asset_object_id = normalize_optional_string(input.asset_object_id); let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?; @@ -6556,6 +6742,8 @@ pub(crate) async fn persist_editor_generated_asset( asset_kind: normalize_optional_string(input.asset_kind.clone()), generation_inputs_json: generation_inputs_json.clone(), updated_at_micros: now_micros, + image_sequence_frames_json: image_sequence_frames_json.clone(), + image_sequence_duration_ms: input.image_sequence_duration_ms, }) .await .map_err(map_editor_project_error)?; @@ -6597,6 +6785,8 @@ pub(crate) async fn persist_editor_generated_asset( thumbnail_src: normalize_optional_string(input.thumbnail_src), group_task_id: normalize_optional_string(input.group_task_id), group_task_expected_asset_count: input.group_task_expected_asset_count, + image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, }) .await .map_err(map_editor_project_error)?; @@ -6642,6 +6832,8 @@ pub(crate) async fn persist_editor_generated_media_asset( generation_inputs: input.generation_inputs, thumbnail_src: input.thumbnail_src, generation_cost_mud_points: input.generation_cost_mud_points, + image_sequence_frames: input.image_sequence_frames, + image_sequence_duration_ms: input.image_sequence_duration_ms, }, ) .await?; @@ -6941,7 +7133,6 @@ fn replace_editor_canvas_layer_with_resource( "sourceType".to_string(), Value::String(resource.source_type.clone()), ); - object.insert("mediaType".to_string(), Value::String("image".to_string())); set_optional_json_string_field(object, "prompt", resource.prompt.as_ref()); set_optional_json_string_field(object, "actualPrompt", resource.actual_prompt.as_ref()); set_optional_json_string_field(object, "model", resource.model.as_ref()); @@ -7172,8 +7363,6 @@ fn build_generated_canvas_layer_item( let placeholder_height = positive_f64(placeholder.height, height); let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; - let asset_kind = resource.asset_kind.as_deref(); - json!({ "layerId": generated_layer_id, "resourceId": resource.resource_id, @@ -7187,7 +7376,6 @@ fn build_generated_canvas_layer_item( "originalHeight": original_height, "zIndex": z_index, "sourceType": "generated", - "mediaType": generated_canvas_media_type(asset_kind), "prompt": resource.prompt, "actualPrompt": resource.actual_prompt, "model": resource.model, @@ -7220,7 +7408,6 @@ pub(crate) fn build_editor_canvas_generated_layer_item( "resourceId": input.resource_id, "title": input.title, "src": input.src, - "mediaType": input.media_type, "thumbnailSrc": input.thumbnail_src, "x": x, "y": y, @@ -7240,8 +7427,34 @@ pub(crate) fn build_editor_canvas_generated_layer_item( "sourceResourceId": input.source_resource_id, "assetKind": input.asset_kind, "generationInputs": input.generation_inputs, - "imageSequenceFrames": input.image_sequence_frames, - "previewVideoPath": input.preview_video_path, + }) +} + +pub(crate) fn build_editor_canvas_resource_placement_item( + completion: &EditorCanvasGenerationCompletionRequest, + resource: &EditorProjectResourcePayload, + layer_id: String, +) -> Value { + let placeholder = &completion.placeholder; + let original_width = positive_f64(resource.width as f64, placeholder.original_width); + let original_height = positive_f64(resource.height as f64, placeholder.original_height); + let width = positive_f64(placeholder.width, original_width); + let height = positive_f64(placeholder.height, original_height); + let placeholder_width = positive_f64(placeholder.width, width); + let placeholder_height = positive_f64(placeholder.height, height); + let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; + let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; + + json!({ + "layerId": layer_id, + "resourceId": resource.resource_id, + "title": completion.title.trim(), + "x": x, + "y": y, + "width": width, + "height": height, + "originalWidth": original_width, + "originalHeight": original_height, }) } @@ -7259,14 +7472,6 @@ fn generated_canvas_layer_id(resource_id: &str) -> String { format!("layer-editor-resource-{resource_id}") } -fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { - match asset_kind { - Some("video") => "video", - Some("sound-effect") | Some("background-music") => "audio", - _ => "image", - } -} - fn build_primary_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, primary_resource: Option<&EditorProjectResourcePayload>, @@ -7588,6 +7793,9 @@ pub(crate) fn serialize_editor_asset_metadata( if metadata.is_null() { return Ok(None); } + if metadata.as_object().is_some_and(serde_json::Map::is_empty) { + return Ok(None); + } if value_contains_forbidden_editor_persisted_media(&metadata) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -7613,6 +7821,76 @@ pub(crate) fn serialize_editor_asset_metadata( Ok(Some(payload)) } +fn serialize_editor_generation_inputs( + asset_kind: Option<&str>, + metadata: Option, +) -> Result, AppError> { + if asset_kind == Some(EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND) + && let Some(inputs) = metadata.as_ref().and_then(Value::as_object) + { + let legacy_runtime_keys = [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ]; + let rejected_keys = legacy_runtime_keys + .into_iter() + .filter(|key| inputs.contains_key(*key)) + .collect::>(); + if !rejected_keys.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "generationInputs 不能写入旧版角色动作运行元数据;请使用 imageSequenceFrames 和 imageSequenceDurationMs。", + "fields": rejected_keys, + })), + ); + } + } + serialize_editor_asset_metadata(metadata) +} + +pub(crate) fn serialize_editor_image_sequence_frames( + frames: Option, +) -> Result, AppError> { + let Some(frames) = frames else { + return Ok(None); + }; + if !frames.is_array() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片序列帧必须是数组。", + })), + ); + }; + if frames + .as_array() + .is_some_and(|frames| frames.iter().any(|frame| frame.get("frameIndex").is_some())) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片序列帧不能包含旧版 frameIndex;数组位置是唯一播放顺序。", + "fields": ["frameIndex"], + })), + ); + } + let Some(frames) = normalize_editor_image_sequence_frames_value(&frames) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片序列帧至少需要两帧,且不能包含无效帧。", + })), + ); + }; + serialize_editor_asset_metadata(Some(frames)) +} + pub(crate) fn normalize_optional_string(value: Option) -> Option { value .map(|item| item.trim().to_string()) @@ -8046,6 +8324,8 @@ async fn persist_editor_provider_source_resource( generation_inputs: input.generation_inputs, thumbnail_src: None, generation_cost_mud_points: input.generation_cost_mud_points, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await @@ -8531,6 +8811,152 @@ mod tests { thread, }; + #[test] + fn editor_asset_payload_uses_formal_sequence_fields_only() { + let stored_formal_frames = json!([ + { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + ]); + let formal_frames = json!([ + { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + ]); + let record = EditorAssetRecord { + asset_id: "editor-asset-action".to_string(), + folder_id: "project".to_string(), + label: "角色动作".to_string(), + asset_object_id: Some("asset-object-frame-1".to_string()), + image_src: "/generated/formal/frame01.png".to_string(), + object_key: Some("generated/formal/frame01.png".to_string()), + width: 192, + height: 256, + source_type: "generated".to_string(), + prompt: Some("挥手".to_string()), + actual_prompt: None, + model: Some("seedance2.0-fast".to_string()), + provider: Some("Ark".to_string()), + task_id: Some("task-action".to_string()), + asset_kind: Some("character-animation".to_string()), + generation_inputs: Some(json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "references": [] + })), + source_resource_id: Some("editor-resource-action".to_string()), + public_showcase_enabled: Some(false), + thumbnail_src: Some("/generated/formal/frame01.png".to_string()), + generation_cost_mud_points: 0, + showcase_id: None, + showcase_review_status: None, + showcase_display_enabled: None, + showcase_like_count: None, + created_at: "2026-07-28T00:00:00.000Z".to_string(), + updated_at: "2026-07-28T00:00:00.000Z".to_string(), + image_sequence_frames: Some(stored_formal_frames), + image_sequence_duration_ms: Some(4_000), + }; + let payload = editor_asset_payload_from_record(record.clone()); + + assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); + assert_eq!(payload.image_sequence_frames, Some(formal_frames.clone())); + assert_eq!(payload.image_sequence_duration_ms, Some(4_000)); + assert_eq!( + payload.generation_inputs, + Some(json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "references": [] + })) + ); + + let mut formal_mp4_record = record.clone(); + formal_mp4_record.image_src = "/generated/formal/action.mp4".to_string(); + formal_mp4_record.object_key = Some("generated/formal/action.mp4".to_string()); + formal_mp4_record.generation_inputs = Some(json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "references": [] + })); + let formal_mp4 = editor_asset_payload_from_record(formal_mp4_record); + assert_eq!( + formal_mp4.asset_kind.as_deref(), + Some("character-animation") + ); + assert_eq!( + formal_mp4.image_sequence_frames, + Some(formal_frames.clone()) + ); + assert_eq!(formal_mp4.image_sequence_duration_ms, Some(4_000)); + + let mut duration_only_record = record.clone(); + duration_only_record.image_sequence_frames = None; + let duration_only = editor_asset_payload_from_record(duration_only_record); + assert!(duration_only.image_sequence_frames.is_none()); + assert_eq!(duration_only.image_sequence_duration_ms, Some(4_000)); + + let mut frames_only_record = record; + frames_only_record.image_sequence_duration_ms = None; + let frames_only = editor_asset_payload_from_record(frames_only_record); + assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); + assert!(frames_only.image_sequence_duration_ms.is_none()); + } + + #[test] + fn editor_project_resource_payload_uses_formal_sequence_fields_only() { + let formal_frames = json!([ + { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + ]); + let record = EditorProjectResourceRecord { + resource_id: "editor-resource-action".to_string(), + project_id: "proj-action".to_string(), + owner_user_id: "user-action".to_string(), + asset_object_id: None, + image_src: "/generated/formal/frame01.png".to_string(), + object_key: None, + width: 192, + height: 256, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("character-animation".to_string()), + generation_inputs: Some(json!({ "fields": [], "references": [] })), + public_showcase_enabled: false, + created_at: "2026-07-28T00:00:00.000Z".to_string(), + updated_at: "2026-07-28T00:00:00.000Z".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: Some(4_000), + }; + + let duration_only = editor_project_resource_payload_from_record(record.clone()); + assert!(duration_only.image_sequence_frames.is_none()); + assert_eq!(duration_only.image_sequence_duration_ms, Some(4_000)); + + let mut formal_mp4_record = record.clone(); + formal_mp4_record.image_src = "/generated/formal/action.mp4".to_string(); + formal_mp4_record.object_key = Some("generated/formal/action.mp4".to_string()); + formal_mp4_record.image_sequence_frames = Some(formal_frames.clone()); + formal_mp4_record.generation_inputs = None; + let formal_mp4 = editor_project_resource_payload_from_record(formal_mp4_record); + assert_eq!( + formal_mp4.asset_kind.as_deref(), + Some("character-animation") + ); + assert_eq!( + formal_mp4.image_sequence_frames, + Some(formal_frames.clone()) + ); + assert_eq!(formal_mp4.image_sequence_duration_ms, Some(4_000)); + + let mut frames_only_record = record; + frames_only_record.image_sequence_frames = Some(formal_frames.clone()); + frames_only_record.image_sequence_duration_ms = None; + let frames_only = editor_project_resource_payload_from_record(frames_only_record); + assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); + assert!(frames_only.image_sequence_duration_ms.is_none()); + } + fn encode_test_png(width: u32, height: u32) -> Vec { let image = image::DynamicImage::new_rgba8(width, height); let mut bytes = Cursor::new(Vec::new()); @@ -8670,6 +9096,8 @@ mod tests { asset_kind: Some(asset_kind.to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -8694,6 +9122,58 @@ mod tests { ); } + #[test] + fn character_animation_canvas_completion_writes_placement_only() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-action".to_string()), + title: "角色动作".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 120.0, + y: 80.0, + width: 420.0, + height: 420.0, + original_width: 512.0, + original_height: 512.0, + }, + }; + let mut resource = editor_project_resource_for_canvas_test( + "resource-action", + EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND, + 512, + 512, + ); + resource.source_resource_id = Some("resource-preview".to_string()); + resource.image_sequence_frames = Some(json!([ + { "imageSrc": "/generated/action/frame01.png", "width": 512, "height": 512 }, + { "imageSrc": "/generated/action/frame02.png", "width": 512, "height": 512 } + ])); + resource.image_sequence_duration_ms = Some(4_000); + + let item = build_editor_canvas_resource_placement_item( + &completion, + &resource, + "layer-action".to_string(), + ); + + assert_eq!(item["layerId"], json!("layer-action")); + assert_eq!(item["resourceId"], json!("resource-action")); + for field in [ + "mediaType", + "assetKind", + "generationInputs", + "imageSequenceFrames", + "imageSequenceDurationMs", + "previewVideoPath", + "sourceResourceId", + "src", + ] { + assert!( + item.get(field).is_none(), + "{field} must remain resource-owned" + ); + } + } + #[test] fn editor_project_revision_conflicts_map_to_http_conflict() { let error = map_editor_project_error(SpacetimeClientError::Procedure( @@ -8733,10 +9213,8 @@ mod tests { } #[test] - fn screen_background_decision_records_hex_without_touching_visible_fields() { + fn screen_background_decision_persists_reusable_hex_with_generation_inputs() { let decision = manual_screen_background_decision("#B0C2E0"); - - // 用户可见的 fields 不被注入抠图背景色,只写内部 screenColorHex。 let updated = apply_editor_screen_background_decision_to_generation_inputs( Some(json!({ "fields": [{ "title": "角色设定", "value": "红发骑士" }], @@ -8745,46 +9223,22 @@ mod tests { Some(&decision), ) .expect("generation inputs should be preserved"); + assert_eq!(updated["screenColorHex"], json!("#B0C2E0")); assert_eq!( updated["fields"], json!([{ "title": "角色设定", "value": "红发骑士" }]), ); - - // 无 generation_inputs 时创建骨架并写入内部元数据,fields 保持为空。 - let created = - apply_editor_screen_background_decision_to_generation_inputs(None, Some(&decision)) - .expect("missing generation inputs should still record the decision"); assert_eq!( - created, - json!({ - "fields": [], - "references": [], - "screenColorHex": "#B0C2E0", - }), + serialize_editor_asset_metadata(Some(updated)) + .expect("generation inputs should serialize") + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + .and_then(|payload| payload["screenColorHex"].as_str().map(str::to_string)), + Some("#B0C2E0".to_string()), ); } - #[test] - fn screen_background_decision_leaves_existing_visible_fields_untouched() { - let decision = manual_screen_background_decision("#CFEFFF"); - - // 即使旧数据的 fields 里残留了抠图背景色,后端也不修改用户可见字段。 - let updated = apply_editor_screen_background_decision_to_generation_inputs( - Some(json!({ - "fields": [{ "title": "抠图背景色", "value": "旧值" }], - "references": [], - })), - Some(&decision), - ) - .expect("generation inputs should be preserved"); - assert_eq!( - updated["fields"], - json!([{ "title": "抠图背景色", "value": "旧值" }]), - ); - assert_eq!(updated["screenColorHex"], json!("#CFEFFF")); - } - fn apply_editor_canvas_generation_completion( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, @@ -8893,6 +9347,8 @@ mod tests { rejected_at: None, updated_at: "2026-06-23T00:00:00.000Z".to_string(), showcase_category: Some("characters".to_string()), + image_sequence_frames: None, + image_sequence_duration_ms: None, } } @@ -8916,6 +9372,8 @@ mod tests { source_resource_id: None, asset_kind: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), @@ -8977,6 +9435,95 @@ mod tests { assert_eq!(value["authorPublicUserCode"], json!("SY-00000000")); } + #[test] + fn editor_showcase_payload_uses_formal_sequence_metadata_only() { + let formal_frames = json!([ + { + "imageSrc": "/generated/formal/frame-0001.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/formal/frame-0002.png", + "width": 192, + "height": 256 + } + ]); + let mut record = test_editor_showcase_asset_record("user-author-1"); + record.image_src = "/generated/formal/action.mp4".to_string(); + record.object_key = Some("generated/formal/action.mp4".to_string()); + record.asset_kind = Some("character-animation".to_string()); + record.image_sequence_frames = Some(formal_frames); + record.image_sequence_duration_ms = Some(6_000); + record.generation_inputs = Some(json!({ + "fields": [{ "title": "动作", "value": "挥手" }] + })); + + let payload = editor_project_resource_payload_from_showcase_record(record); + + assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); + assert_eq!(payload.image_sequence_duration_ms, Some(6_000)); + assert_eq!( + payload + .image_sequence_frames + .as_ref() + .and_then(Value::as_array) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("imageSrc")), + Some(&json!("/generated/formal/frame-0001.png")) + ); + assert!( + payload + .image_sequence_frames + .as_ref() + .is_some_and(|frames| { + frames.as_array().is_some_and(|frames| { + frames.iter().all(|frame| frame.get("frameIndex").is_none()) + }) + }) + ); + assert_eq!( + payload.generation_inputs, + Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })) + ); + } + + #[test] + fn editor_showcase_payload_does_not_backfill_missing_formal_sequence_fields() { + let formal_frames = json!([ + { "imageSrc": "/generated/formal/frame-0001.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame-0002.png", "width": 192, "height": 256 } + ]); + let legacy_generation_inputs = json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "characterAnimation": { + "frames": [ + { "imageSrc": "/generated/legacy/frame-0001.png", "width": 64, "height": 64 }, + { "imageSrc": "/generated/legacy/frame-0002.png", "width": 64, "height": 64 } + ], + "durationSeconds": 9 + } + }); + let mut duration_only_record = test_editor_showcase_asset_record("user-author-1"); + duration_only_record.asset_kind = Some("character-animation".to_string()); + duration_only_record.generation_inputs = Some(legacy_generation_inputs.clone()); + duration_only_record.image_sequence_duration_ms = Some(5_000); + + let duration_only = + editor_project_resource_payload_from_showcase_record(duration_only_record); + assert_eq!(duration_only.image_sequence_duration_ms, Some(5_000)); + assert!(duration_only.image_sequence_frames.is_none()); + + let mut frames_only_record = test_editor_showcase_asset_record("user-author-1"); + frames_only_record.asset_kind = Some("character-animation".to_string()); + frames_only_record.generation_inputs = Some(legacy_generation_inputs); + frames_only_record.image_sequence_frames = Some(formal_frames.clone()); + + let frames_only = editor_project_resource_payload_from_showcase_record(frames_only_record); + assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); + assert!(frames_only.image_sequence_duration_ms.is_none()); + } + #[test] fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() { let resources = vec![EditorProjectResourcePayload { @@ -9003,6 +9550,8 @@ mod tests { asset_kind: Some("spec".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -9018,15 +9567,17 @@ mod tests { { "layerId": "layer-1", "resourceId": "resource-1", + "mediaType": "image-sequence", "src": "data:image/png;base64,layer", "thumbnailSrc": "data:image/png;base64,thumbnail", "imageSequenceFrames": [ - { "frameIndex": 0, "imageSrc": "data:image/png;base64,frame" } + { "imageSrc": "data:image/png;base64,frame" } ], "generationInputs": { "references": [ { "resourceId": "resource-1", + "mediaType": "image", "src": "data:image/png;base64,reference" } ] @@ -9058,6 +9609,11 @@ mod tests { sanitized[0]["generationInputs"]["references"][0]["src"], json!("/generated-character-drafts/editor/spec.png") ); + assert!(sanitized[0].get("mediaType").is_none()); + assert_eq!( + sanitized[0]["generationInputs"]["references"][0]["mediaType"], + json!("image") + ); } #[test] @@ -9086,6 +9642,8 @@ mod tests { asset_kind: Some("spec".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -9165,6 +9723,8 @@ mod tests { asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -9222,7 +9782,6 @@ mod tests { "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", "imageSequenceFrames": [ { - "frameIndex": 1, "imageSrc": "data:image/png;base64,frame" } ] @@ -9238,7 +9797,6 @@ mod tests { "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", "imageSequenceFrames": [ { - "frameIndex": 1, "imageSrc": "/generated-character-drafts/editor/frame-1.png" } ], @@ -9293,6 +9851,55 @@ mod tests { ); } + #[test] + fn action_generation_inputs_reject_legacy_runtime_fields_only_for_actions() { + for (field, value) in [ + ("characterAnimation", json!({ "frames": [] })), + ("frames", json!([])), + ("previewVideoPath", json!("/generated/action/preview.mp4")), + ("frameCount", json!(32)), + ("fps", json!(8)), + ("durationSeconds", json!(4)), + ("screenColorHex", json!("#00ff00")), + ] { + let mut metadata = serde_json::Map::new(); + metadata.insert("fields".to_string(), json!([])); + metadata.insert(field.to_string(), value); + let metadata = Value::Object(metadata); + let error = serialize_editor_generation_inputs( + Some(EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND), + Some(metadata.clone()), + ) + .expect_err("legacy animation runtime metadata must fail explicitly"); + assert!(error.body_text().contains("imageSequenceFrames")); + assert!(error.body_text().contains("imageSequenceDurationMs")); + assert!(serialize_editor_generation_inputs(Some("image"), Some(metadata)).is_ok()); + } + } + + #[test] + fn editor_sequence_frame_serialization_rejects_frame_index() { + let error = serialize_editor_image_sequence_frames(Some(json!([ + { + "frameIndex": 99, + "imageSrc": "/generated/action/frame01.png", + "objectKey": "generated/action/frame01.png", + "width": 192, + "height": 256 + }, + { + "frameIndex": 1, + "imageSrc": "/generated/action/frame02.png", + "assetObjectId": "asset-object-frame02", + "width": 192, + "height": 256 + } + ]))) + .expect_err("formal sequence must use array order without frameIndex"); + + assert!(error.body_text().contains("frameIndex")); + } + #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); @@ -10311,6 +10918,8 @@ mod tests { "fields": [{"title": "角色设定", "value": "银发游侠"}], "references": [] })), + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -10343,7 +10952,7 @@ mod tests { assert_eq!(result_layer["originalWidth"], json!(1024.0)); assert_eq!(result_layer["originalHeight"], json!(1536.0)); assert_eq!(result_layer["zIndex"], json!(5)); - assert_eq!(result_layer["mediaType"], json!("image")); + assert_eq!(result_layer["assetKind"], json!("character")); assert_eq!( result_layer["src"], json!("data:image/png;base64,character") @@ -10416,6 +11025,8 @@ mod tests { "fields": [{"title": "prompt", "value": "角色"}], "references": [] })), + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -10535,6 +11146,8 @@ mod tests { asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -10628,6 +11241,8 @@ mod tests { asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -11152,6 +11767,8 @@ mod tests { asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, review_status: None, display_enabled: None, @@ -11279,6 +11896,8 @@ mod tests { source_resource_id: None, asset_kind: Some("icon-spec".to_string()), generation_inputs: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), @@ -11315,6 +11934,8 @@ mod tests { asset_kind: Some("icon-spec".to_string()), generation_inputs: None, source_resource_id: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, public_showcase_enabled: Some(true), generation_cost_mud_points: 0, showcase_id: None, diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 4f9d6664c..71cfef0ba 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1,20 +1,3 @@ -use axum::{ - Json, - extract::{Extension, Path, State, rejection::JsonRejection}, - http::{StatusCode, header::CONTENT_TYPE}, - response::{IntoResponse, Response}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use shared_kernel::build_prefixed_uuid_id; -use spacetime_client::{ - EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, - EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, -}; - use crate::{ api_response::json_success_body, character_animation_assets::{ @@ -43,6 +26,23 @@ use crate::{ generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, }, }; +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::{ + EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, + EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, + EditorProjectGetRecordInput, EditorProjectRenameRecordInput, + EditorProjectResourceCreateRecordInput, +}; +use std::num::NonZeroU64; const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api"; const SCOPE_EDITOR_PROJECT: &str = "editor:project"; @@ -104,6 +104,8 @@ pub struct ExternalEditorAssetCreateRequest { task_id: Option, asset_kind: Option, generation_inputs: Option, + image_sequence_frames: Option, + image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] @@ -123,6 +125,8 @@ pub struct ExternalEditorProjectResourceCreateRequest { source_resource_id: Option, asset_kind: Option, generation_inputs: Option, + image_sequence_frames: Option, + image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] @@ -468,6 +472,9 @@ pub async fn create_external_editor_asset( require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( + payload.image_sequence_frames, + )?; let object_key = normalize_optional_string(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state @@ -496,6 +503,8 @@ pub async fn create_external_editor_asset( thumbnail_src: None, group_task_id: None, group_task_expected_asset_count: None, + image_sequence_frames_json, + image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get), }) .await .map_err(map_editor_project_error)?; @@ -570,6 +579,9 @@ pub async fn create_external_editor_project_resource( require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( + payload.image_sequence_frames, + )?; let object_key = normalize_optional_string(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state @@ -593,6 +605,8 @@ pub async fn create_external_editor_project_resource( asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, updated_at_micros: current_utc_micros(), + image_sequence_frames_json, + image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get), }) .await .map_err(map_editor_project_error)?; diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 3c854074d..fdc97d6d9 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -268,6 +268,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(normalized.price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await @@ -285,7 +287,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( resource_id, title: completion.title.trim().to_string(), src: audio_src.clone(), - media_type: Some("audio".to_string()), thumbnail_src: None, original_width: EDITOR_AUDIO_WIDTH, original_height: EDITOR_AUDIO_HEIGHT, @@ -303,8 +304,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( source_resource_id: None, asset_kind: Some("sound-effect".to_string()), generation_inputs, - image_sequence_frames: None, - preview_video_path: None, }, ); complete_editor_canvas_generation_with_items( @@ -488,6 +487,8 @@ pub(crate) async fn generate_editor_background_music_for_owner( generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(normalized.price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, ) .await @@ -509,7 +510,6 @@ pub(crate) async fn generate_editor_background_music_for_owner( }), title: completion.title.trim().to_string(), src: audio_src.clone(), - media_type: Some("audio".to_string()), thumbnail_src: None, original_width: EDITOR_AUDIO_WIDTH, original_height: EDITOR_AUDIO_HEIGHT, @@ -527,8 +527,6 @@ pub(crate) async fn generate_editor_background_music_for_owner( source_resource_id: None, asset_kind: Some("background-music".to_string()), generation_inputs, - image_sequence_frames: None, - preview_video_path: None, }, ); complete_editor_canvas_generation_with_items( -- 2.52.0 From 8c2f48d81662a572ff7bd756cf87e71e822dc928 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:21:46 +0800 Subject: [PATCH 04/42] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=94=BB=E5=B8=83=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端只从正式 resource 或 asset 的图片序列字段恢复、复用、播放和导出角色动作。 动作 layout 只保存资源引用与 placement,不复制帧、时长、生成输入或 mediaType。 项目生成必须绑定正式资源,无项目放置必须绑定正式账号素材,缺失时失败关闭。 补齐素材库拖放、序列 ZIP、画布播放、保存恢复与生成工作流测试。 --- ...mageCanvasEditorAssetsIntegration.test.tsx | 168 ++++++++ ...CanvasEditorGenerationIntegration.test.tsx | 24 +- .../ImageCanvasEditorModel.test.ts | 287 ++++++++++++-- .../image-editor/ImageCanvasEditorModel.ts | 311 +++++++++------ .../image-editor/ImageCanvasEditorTypes.ts | 10 +- .../ImageCanvasEditorView.test-utils.ts | 25 +- .../image-editor/ImageCanvasEditorView.tsx | 30 +- .../ImageCanvasExportModel.test.ts | 13 +- .../image-editor/ImageCanvasExportModel.ts | 25 +- .../ImageCanvasGenerationLayerModel.test.ts | 113 +++++- .../ImageCanvasGenerationLayerModel.ts | 43 ++- .../ImageCanvasGenerationModel.test.ts | 15 +- .../ImageCanvasGenerationModel.ts | 9 +- .../ImageCanvasHistoryModel.test.ts | 1 - .../image-editor/ImageCanvasHistoryModel.ts | 4 +- ...ageCanvasProjectCoverSnapshotModel.test.ts | 1 - .../ImageCanvasProjectCoverSnapshotModel.ts | 3 +- .../image-editor/ImageCanvasUploadModel.ts | 17 +- .../image-editor/ImageCanvasUploadUtils.ts | 16 + .../ImageCanvasWorldView.test.tsx | 138 +++---- .../image-editor/ImageCanvasWorldView.tsx | 362 +++++++++--------- .../useImageCanvasAssetCanvasBridge.ts | 42 +- ...useImageCanvasAssetExportWorkflow.test.tsx | 12 +- .../useImageCanvasAssetExportWorkflow.ts | 30 +- .../useImageCanvasAssetLibrary.test.tsx | 1 + ...anvasGenerationSubmissionWorkflow.test.tsx | 38 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 6 + .../useImageCanvasGenerationWorkflow.ts | 3 +- .../useImageCanvasProjectPersistence.ts | 9 +- .../useImageCanvasUploadWorkflow.test.tsx | 4 - .../useImageCanvasUploadWorkflow.ts | 8 +- .../image-editor/editorProjectClient.test.ts | 122 +++++- .../image-editor/editorProjectClient.ts | 20 +- 33 files changed, 1344 insertions(+), 566 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasUploadUtils.ts diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 27e840204..411f694a1 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -165,6 +165,7 @@ function createTestImageAsset({ width: 320, height: 240, sourceType, + assetKind: 'image' as const, sourceResourceId, }; } @@ -344,6 +345,85 @@ describe('ImageCanvasEditorView asset library integration', () => { } }); + it('downloads a character action from the asset library as a sequence zip', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action-download', + folderId: 'project', + label: '角色挥手', + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/frame02.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 4_000, + }, + ], + }); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (..._args: Parameters) => + new Response(new Blob(['frame'], { type: 'image/png' })), + ); + globalThis.fetch = fetchMock as typeof fetch; + let downloadName = ''; + const downloadCapture: { blob: Blob | null } = { blob: null }; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + downloadCapture.blob = blob; + return 'blob:character-action-zip'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + downloadName = this.download; + }, + ); + + try { + render(); + fireEvent.click( + await screen.findByRole('button', { name: '下载素材角色挥手' }), + ); + + await waitFor(() => { + expect(downloadName).toBe('角色挥手-SpineJSON.zip'); + }); + const fetchedUrls = fetchMock.mock.calls.map(([url]) => url); + expect(fetchedUrls).toContain('/generated/action/frame01.png'); + expect(fetchedUrls).toContain('/generated/action/frame02.png'); + expect(downloadCapture.blob?.type).toBe('application/zip'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('collapses folders, creates upload folders, and deletes uploaded materials', async () => { const user = userEvent.setup(); const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image'); @@ -1353,6 +1433,94 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('drags a character action onto the canvas as a saved playable sequence', async () => { + const frames = [ + { + imageSrc: '/generated/action/run-frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/run-frame02.png', + width: 192, + height: 256, + }, + ]; + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action-run', + folderId: 'project', + label: '角色奔跑', + imageSrc: frames[0]!.imageSrc, + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + ], + }); + render(); + + const sourceAsset = await screen.findByRole('button', { + name: '添加角色奔跑', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); + const viewport = screen.getByLabelText('画布工作区'); + const dataTransfer = createDataTransferStub(); + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.drop(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + expect( + await screen.findByAltText('画布序列帧:角色奔跑'), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }), + ).toBeTruthy(); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }), + ); + }); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '角色奔跑', + }), + ]), + }), + ); + }); + }); + it('keeps the source resource id when dragging a generated asset onto the viewport', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index c307a36ee..b8c7e8285 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3198,20 +3198,32 @@ describe('ImageCanvasEditorView generation integration', () => { resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); + const animationFrames = Array.from({ length: 48 }, (_, index) => ({ + imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, + width: 1024, + height: 1024, + })); generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', - frames: Array.from({ length: 48 }, (_, index) => ({ - frameIndex: index + 1, - imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, - width: 1024, - height: 1024, - })), + frames: animationFrames, frameCount: 48, durationSeconds: 6, fps: 8, + resource: { + resourceId: 'resource-character-animation-final', + projectId: 'editor-project-character-animation', + imageSrc: animationFrames[0]!.imageSrc, + width: 1024, + height: 1024, + sourceType: 'generated', + sourceResourceId: 'resource-character-animation-preview', + assetKind: 'character-animation', + imageSequenceFrames: animationFrames, + imageSequenceDurationMs: 6_000, + }, }); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const requestUrl = diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index c613b81d7..3e0eb519f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -39,9 +39,9 @@ describe('ImageCanvasEditorModel', () => { expect( viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }), ).toEqual({ x: 10, y: 20, scale: 0.5 }); - expect( - canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }), - ).toEqual({ x: 10, y: 20, scale: 1 }); + expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual( + { x: 10, y: 20, scale: 1 }, + ); }); it('normalizes valid canvas background hex values and rejects invalid input', () => { @@ -83,7 +83,9 @@ describe('ImageCanvasEditorModel', () => { }); expect(layout).toEqual([]); - expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined(); + expect( + splitCanvasLayoutItems(layout).canvasBackgroundColor, + ).toBeUndefined(); expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc'); }); @@ -112,7 +114,7 @@ describe('ImageCanvasEditorModel', () => { expect(library.folders[0]?.id).toBe('project'); }); - it('infers MP3 and MP4 asset media types from persisted paths', () => { + it('uses media types published by the asset API', () => { const library = normalizeAssetLibrary({ folders: [ { @@ -128,10 +130,12 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-audio', folderId: 'project', label: '胜利音效.mp3', - imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', + imageSrc: + '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', width: 420, height: 120, sourceType: 'uploaded', + assetKind: 'audio', objectKey: 'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', }, @@ -139,12 +143,14 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-video', folderId: 'project', label: '开场动画.mp4', - imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', + imageSrc: + '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', thumbnailSrc: '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', width: 560, height: 315, sourceType: 'uploaded', + assetKind: 'video', objectKey: 'generated-character-drafts/editor/asset-library/video/开场动画.mp4', }, @@ -166,10 +172,63 @@ describe('ImageCanvasEditorModel', () => { ), ).toMatchObject({ mediaType: 'audio', - assetKind: 'sound-effect', + assetKind: 'audio', }); }); + it('infers kinds for legacy persisted media assets from source extensions', () => { + const library = normalizeAssetLibrary({ + folders: [], + assets: [ + { + assetId: 'legacy-video', + folderId: 'project', + label: '旧开场动画.mp4', + imageSrc: '/legacy/opening.mp4?signature=video', + width: 1280, + height: 720, + sourceType: 'uploaded', + }, + { + assetId: 'legacy-audio', + folderId: 'project', + label: '旧背景音乐', + imageSrc: '/api/assets/read', + objectKey: 'legacy/music.mp3?version=1', + width: 420, + height: 120, + sourceType: 'uploaded', + assetKind: 'unrecognized-legacy-kind', + }, + ], + }); + + expect(library.assets[0]).toMatchObject({ + mediaType: 'video', + assetKind: 'video', + }); + expect(library.assets[1]).toMatchObject({ + mediaType: 'audio', + assetKind: 'background-music', + }); + expect( + library.assets.map((asset, index) => + createLayerFromAsset( + asset, + index, + { x: 0, y: 0, scale: 1 }, + { x: 300, y: 200 }, + ), + ), + ).toEqual([ + expect.objectContaining({ mediaType: 'video', assetKind: 'video' }), + expect.objectContaining({ + mediaType: 'audio', + assetKind: 'background-music', + }), + ]); + }); + it('creates a cascaded layer from an account asset near the requested screen point', () => { const asset: EditorAsset = { id: 'asset-1', @@ -252,10 +311,12 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-generated', folderId: 'project', label: '生成素材', - imageSrc: '/generated-character-drafts/editor/asset-library/generated.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/generated.png', width: 640, height: 640, sourceType: 'generated', + assetKind: 'image', sourceResourceId: 'resource-generated', }, ], @@ -267,6 +328,87 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('round-trips an explicit character action into a movable sequence layer', () => { + const frames = [ + { + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/frame02.png', + width: 192, + height: 256, + }, + ]; + const library = normalizeAssetLibrary({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action', + folderId: 'project', + label: '角色挥手', + imageSrc: frames[0]!.imageSrc, + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + ], + }); + + const asset = library.assets[0] as EditorAsset; + const layer = createLayerFromAsset( + asset, + 1, + { x: 0, y: 0, scale: 1 }, + { x: 400, y: 300 }, + { applyCascadeOffset: false }, + ); + + expect(asset.mediaType).toBe('image-sequence'); + expect(layer).toMatchObject({ + mediaType: 'image-sequence', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + x: 304, + y: 172, + }); + }); + + it('rejects an explicitly typed but incomplete character action', () => { + expect(() => + createLayerFromAsset( + { + id: 'asset-corrupt-action', + label: '损坏动作', + src: '/generated/action/frame01.png', + mediaType: 'image-sequence', + width: 192, + height: 256, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'generated', + persisted: true, + assetKind: 'character-animation', + }, + 1, + { x: 0, y: 0, scale: 1 }, + { x: 400, y: 300 }, + ), + ).toThrow('缺少可用序列帧'); + }); + it('serializes and hydrates canvas layer metadata without embedding image payloads', () => { const layer: CanvasLayer = { id: 'layer-generated', @@ -323,6 +465,48 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); + it('derives audio and video media types from assetKind without persisting mediaType', () => { + for (const [mediaType, assetKind] of [ + ['video', 'video'], + ['audio', 'background-music'], + ] as const) { + const resourceId = `${mediaType}-resource`; + const resources = new Map([ + [ + resourceId, + { + imageSrc: + mediaType === 'video' + ? '/legacy/cutscene.mp4' + : '/legacy/theme.mp3', + assetKind, + }, + ], + ]); + const firstHydration = hydrateLayer( + { + layerId: `${mediaType}-layer`, + resourceId, + title: mediaType, + sourceType: 'uploaded', + assetKind, + }, + resources, + ); + + expect(firstHydration?.mediaType).toBe(mediaType); + if (!firstHydration) { + throw new Error(`${mediaType} layer should hydrate`); + } + const savedSnapshot = serializeLayer(firstHydration); + expect(savedSnapshot.mediaType).toBeUndefined(); + expect(savedSnapshot.assetKind).toBeUndefined(); + + const secondHydration = hydrateLayer(savedSnapshot, resources); + expect(secondHydration?.mediaType).toBe(mediaType); + } + }); + it('hydrates object metadata from project resources when the saved layer is lean', () => { const hydrated = hydrateLayer( { @@ -342,8 +526,10 @@ describe('ImageCanvasEditorModel', () => { [ 'resource-uploaded', { - imageSrc: '/generated-character-drafts/editor/asset-library/image.png', - objectKey: 'generated-character-drafts/editor/asset-library/image.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/image.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image.png', assetObjectId: 'asset-object-uploaded', sourceResourceId: 'resource-source', }, @@ -376,7 +562,9 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', assetKind: 'publication-material', }, - new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]), + new Map([ + ['resource-publication', { imageSrc: '/read/publication.png' }], + ]), ); expect(hydrated).toMatchObject({ @@ -401,14 +589,12 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', mediaType: 'audio', assetKind: 'sound-effect', - durationSeconds: 2.4, }, new Map([ [ 'resource-audio', { imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', - durationSeconds: 2.4, }, ], ]), @@ -419,11 +605,10 @@ describe('ImageCanvasEditorModel', () => { src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', assetKind: 'sound-effect', - durationSeconds: 2.4, }); }); - it('serializes and hydrates image sequence frames for character animation layers', () => { + it('hydrates character animation sequence fields from the project resource', () => { const layer: CanvasLayer = { id: 'layer-action', resourceId: 'resource-action', @@ -433,7 +618,6 @@ describe('ImageCanvasEditorModel', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', assetObjectId: 'assetobj-frame-01', @@ -441,7 +625,6 @@ describe('ImageCanvasEditorModel', () => { height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', assetObjectId: 'assetobj-frame-02', @@ -449,7 +632,6 @@ describe('ImageCanvasEditorModel', () => { height: 1024, }, ], - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', x: 10, y: 20, width: 420, @@ -459,15 +641,14 @@ describe('ImageCanvasEditorModel', () => { zIndex: 6, sourceType: 'generated', assetKind: 'character-animation', - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }; const snapshot = serializeLayer(layer); - expect(snapshot.thumbnailSrc).toBe( - '/generated-character-drafts/editor/frame01.png', - ); - expect(snapshot.mediaType).toBe('image-sequence'); - expect(snapshot.imageSequenceFrames).toHaveLength(2); + expect(snapshot.thumbnailSrc).toBeUndefined(); + expect(snapshot.imageSequenceFrames).toBeUndefined(); + expect(snapshot.imageSequenceDurationMs).toBeUndefined(); + expect(snapshot.mediaType).toBeUndefined(); const hydrated = hydrateLayer( snapshot, @@ -477,6 +658,8 @@ describe('ImageCanvasEditorModel', () => { { imageSrc: layer.src, assetKind: 'character-animation', + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: 4_000, }, ], ]), @@ -486,12 +669,54 @@ describe('ImageCanvasEditorModel', () => { mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: layer.imageSequenceFrames, - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', assetKind: 'character-animation', - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }); }); + it('rejects an action layer when its project resource lacks formal sequence fields', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/legacy-frame01.png', + objectKey: 'generated-character-drafts/editor/legacy-frame01.png', + assetObjectId: 'assetobj-legacy-frame-01', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-character-drafts/editor/legacy-frame02.png', + objectKey: 'generated-character-drafts/editor/legacy-frame02.png', + assetObjectId: 'assetobj-legacy-frame-02', + width: 512, + height: 512, + }, + ]; + const resources = new Map([ + [ + 'resource-legacy-action', + { + imageSrc: frames[0]!.imageSrc, + assetKind: 'character-animation', + }, + ], + ]); + const hydration = hydrateLayer( + { + layerId: 'layer-legacy-action', + resourceId: 'resource-legacy-action', + title: '历史角色动作', + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 3_600, + previewVideoPath: '/generated-character-drafts/editor/legacy-preview.mp4', + }, + resources, + ); + + expect(hydration).toBeNull(); + }); + it('restores audio generation dialogs from saved layout', () => { const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-audio', @@ -659,12 +884,16 @@ describe('ImageCanvasEditorModel', () => { }); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).not.toContain('data:image'); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).toContain('ref:project-resource:resource-reference-1'); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index bbc1db6b7..91595b6de 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -140,6 +140,7 @@ export function createLayerFromAsset( screenCenter: { x: number; y: number }, options: { applyCascadeOffset?: boolean } = {}, ): CanvasLayer { + assertCompleteImageSequenceAsset(asset); const { width, height } = resolveLayerResolutionSize( asset.width, asset.height, @@ -153,19 +154,21 @@ export function createLayerFromAsset( const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; const offset = options.applyCascadeOffset === false ? 0 : index * 34; - const assetKind: CanvasAssetKind | undefined = - asset.mediaType === 'video' - ? 'video' - : asset.mediaType === 'audio' - ? inferAudioAssetKindFromLabel(asset.label) - : undefined; + const assetKind = inferEditorAssetKind( + asset.label, + asset.src, + asset.objectKey, + asset.assetKind, + asset.mediaType, + ); + const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: `layer-${asset.id}-${index}`, resourceId: `local-resource-${asset.id}-${index}`, title: asset.label, src: asset.src, - mediaType: asset.mediaType, + mediaType, x: worldCenterX - width / 2 + offset, y: worldCenterY - height / 2 + offset, width, @@ -183,13 +186,29 @@ export function createLayerFromAsset( objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, sourceResourceId: asset.sourceResourceId, - durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, - assetKind: asset.assetKind ?? assetKind, + assetKind, generationInputs: asset.generationInputs, + imageSequenceFrames: asset.imageSequenceFrames, + imageSequenceDurationMs: asset.imageSequenceDurationMs, } satisfies CanvasLayer; } +export function assertCompleteImageSequenceAsset(asset: EditorAsset) { + if ( + asset.assetKind === 'character-animation' && + (asset.imageSequenceFrames?.length ?? 0) < 2 + ) { + throw new Error(`角色动作素材“${asset.label}”不完整:缺少可用序列帧。`); + } + if ( + asset.assetKind === 'character-animation' && + !(asset.imageSequenceDurationMs && asset.imageSequenceDurationMs > 0) + ) { + throw new Error(`角色动作素材“${asset.label}”不完整:缺少序列播放时长。`); + } +} + export function isInlineEditorMediaSource(value: string | null | undefined) { const normalizedValue = value?.trim().toLowerCase() ?? ''; return ( @@ -233,29 +252,10 @@ function serializeOptionalMediaSource( return normalizedSource; } -function serializeImageSequenceFrames( - frames: CanvasLayer['imageSequenceFrames'], -) { - if (!frames?.length) { - return undefined; - } - const serializedFrames = frames.flatMap((frame) => { - const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey); - return imageSrc - ? [ - { - ...frame, - imageSrc, - }, - ] - : []; - }); - return serializedFrames.length ? serializedFrames : undefined; -} - export function serializeLayer( layer: CanvasLayer, ): EditorProjectLayerSnapshot { + const isCharacterAnimation = layer.assetKind === 'character-animation'; return { layerId: layer.id, resourceId: layer.resourceId, @@ -267,27 +267,25 @@ export function serializeLayer( originalWidth: layer.originalWidth, originalHeight: layer.originalHeight, zIndex: layer.zIndex, - sourceType: layer.sourceType, - mediaType: layer.mediaType, - thumbnailSrc: serializeOptionalMediaSource( - layer.thumbnailSrc, - layer.objectKey, - ), - imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames), - previewVideoPath: serializeOptionalMediaSource( - layer.previewVideoPath, - layer.objectKey, - ), - prompt: layer.prompt, - actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, - taskId: layer.taskId, - objectKey: layer.objectKey, - assetObjectId: layer.assetObjectId, - durationSeconds: layer.durationSeconds, - sourceResourceId: layer.sourceResourceId, - sourceAssetId: layer.sourceAssetId, + ...(isCharacterAnimation + ? {} + : { + sourceType: layer.sourceType, + thumbnailSrc: serializeOptionalMediaSource( + layer.thumbnailSrc, + layer.objectKey, + ), + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + durationSeconds: layer.durationSeconds, + sourceResourceId: layer.sourceResourceId, + sourceAssetId: layer.sourceAssetId, + }), groupId: layer.groupId, hidden: layer.hidden, locked: layer.locked, @@ -691,17 +689,40 @@ export function hydrateLayer( const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const resource = resourcesById.get(resourceId); const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; - const imageSequenceFrames = hydrateImageSequenceFrames( - snapshot.imageSequenceFrames, + const resourceImageSequenceFrames = hydrateImageSequenceFrames( + resource?.imageSequenceFrames, ); + const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind); + const snapshotAssetKind = canvasAssetKindOrNull(snapshot.assetKind); + if ( + snapshotAssetKind === 'character-animation' && + resourceAssetKind !== 'character-animation' + ) { + return null; + } + const assetKind = resourceAssetKind ?? snapshotAssetKind; + const isCharacterAnimation = assetKind === 'character-animation'; + const imageSequenceFrames = isCharacterAnimation + ? resourceImageSequenceFrames + : []; const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? ''; - const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || ''; + const src = isCharacterAnimation + ? firstSequenceFrame || resource?.imageSrc || '' + : snapshotSrc || resource?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } + if ( + isCharacterAnimation && + (imageSequenceFrames.length < 2 || + !(resource?.imageSequenceDurationMs && + resource.imageSequenceDurationMs > 0)) + ) { + return null; + } return { id: layerId, resourceId, @@ -722,38 +743,60 @@ export function hydrateLayer( }; })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), - sourceType: isCanvasSourceType(snapshot.sourceType) - ? snapshot.sourceType - : 'uploaded', - mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames), - thumbnailSrc: stringOrNull(snapshot.thumbnailSrc), + sourceType: isCanvasSourceType(resource?.sourceType) + ? resource.sourceType + : isCanvasSourceType(snapshot.sourceType) + ? snapshot.sourceType + : 'uploaded', + mediaType: resolveCanvasMediaTypeFromAssetKind(assetKind), + thumbnailSrc: isCharacterAnimation + ? firstSequenceFrame || null + : stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length ? imageSequenceFrames : undefined, - previewVideoPath: stringOrNull(snapshot.previewVideoPath), - prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), - actualPrompt: - stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt), - model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model), - provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), - taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), - objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), - assetObjectId: - stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId), - durationSeconds: - audioDurationOrNull(snapshot.durationSeconds) ?? - audioDurationOrNull(resource?.durationSeconds), - sourceResourceId: - stringOrNull(snapshot.sourceResourceId) ?? - stringOrNull(resource?.sourceResourceId), - sourceAssetId: stringOrNull(snapshot.sourceAssetId), + previewVideoPath: isCharacterAnimation + ? null + : stringOrNull(snapshot.previewVideoPath), + prompt: isCharacterAnimation + ? stringOrNull(resource?.prompt) + : stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), + actualPrompt: isCharacterAnimation + ? stringOrNull(resource?.actualPrompt) + : stringOrNull(snapshot.actualPrompt) ?? + stringOrNull(resource?.actualPrompt), + model: isCharacterAnimation + ? stringOrNull(resource?.model) + : stringOrNull(snapshot.model) ?? stringOrNull(resource?.model), + provider: isCharacterAnimation + ? stringOrNull(resource?.provider) + : stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), + taskId: isCharacterAnimation + ? stringOrNull(resource?.taskId) + : stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), + objectKey: isCharacterAnimation + ? stringOrNull(resource?.objectKey) + : stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), + assetObjectId: isCharacterAnimation + ? stringOrNull(resource?.assetObjectId) + : stringOrNull(snapshot.assetObjectId) ?? + stringOrNull(resource?.assetObjectId), + imageSequenceDurationMs: isCharacterAnimation + ? audioDurationOrNull(resource?.imageSequenceDurationMs) + : undefined, + sourceResourceId: isCharacterAnimation + ? stringOrNull(resource?.sourceResourceId) + : stringOrNull(snapshot.sourceResourceId) ?? + stringOrNull(resource?.sourceResourceId), + sourceAssetId: isCharacterAnimation + ? null + : stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), - assetKind: - canvasAssetKindOrNull(resource?.assetKind) ?? - canvasAssetKindOrNull(snapshot.assetKind), - generationInputs: - generationInputsOrNull(resource?.generationInputs) ?? - generationInputsOrNull(snapshot.generationInputs), + assetKind, + generationInputs: isCharacterAnimation + ? generationInputsOrNull(resource?.generationInputs) + : generationInputsOrNull(resource?.generationInputs) ?? + generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -776,11 +819,13 @@ export function mapAssetLibrarySnapshot( persisted: true, })), assets: library.assets.map((asset) => { - const mediaType = inferEditorAssetMediaType( + const assetKind = inferEditorAssetKind( + asset.label, asset.imageSrc, - asset.objectKey ?? undefined, + asset.objectKey, asset.assetKind, ); + const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: asset.assetId, label: asset.label, @@ -807,59 +852,93 @@ export function mapAssetLibrarySnapshot( showcaseReviewStatus: asset.showcaseReviewStatus ?? null, showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null, showcaseLikeCount: asset.showcaseLikeCount ?? null, - assetKind: canvasAssetKindOrNull(asset.assetKind), + assetKind, generationInputs: generationInputsOrNull(asset.generationInputs), - durationSeconds: asset.durationSeconds ?? undefined, + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, }; }), }; } +export function resolveCanvasMediaTypeFromAssetKind( + assetKind?: string | null, +): CanvasMediaType { + if (assetKind === 'character-animation') { + return 'image-sequence'; + } + if (assetKind === 'video') { + return 'video'; + } + if ( + assetKind === 'audio' || + assetKind === 'sound-effect' || + assetKind === 'background-music' + ) { + return 'audio'; + } + return 'image'; +} + export function inferEditorAssetMediaType( imageSrc: string, objectKey?: string | null, assetKind?: string | null, ): CanvasMediaType { - if (assetKind === 'video') { + const normalizedAssetKind = canvasAssetKindOrNull(assetKind); + if (normalizedAssetKind) { + return resolveCanvasMediaTypeFromAssetKind(normalizedAssetKind); + } + + const sources = [objectKey, imageSrc]; + if (sources.some((source) => hasMediaFileExtension(source, '.mp4'))) { return 'video'; } - if (assetKind === 'sound-effect' || assetKind === 'background-music') { - return 'audio'; - } - const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase(); - const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source; - if (sourceWithoutQuery.includes('.mp4')) { - return 'video'; - } - if (sourceWithoutQuery.includes('.mp3')) { + if (sources.some((source) => hasMediaFileExtension(source, '.mp3'))) { return 'audio'; } return 'image'; } -function resolveHydratedLayerMediaType( - snapshot: EditorProjectLayerSnapshot, - imageSequenceFrames: CanvasLayer['imageSequenceFrames'], -): CanvasMediaType { - if (snapshot.mediaType === 'image-sequence') { - return 'image-sequence'; +function inferEditorAssetKind( + label: string, + imageSrc: string, + objectKey?: string | null, + assetKind?: string | null, + mediaType?: CanvasMediaType, +): CanvasAssetKind { + const normalizedAssetKind = canvasAssetKindOrNull(assetKind); + if (normalizedAssetKind) { + return normalizedAssetKind; } - if ( - snapshot.assetKind === 'character-animation' && - (imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc)) - ) { - return 'image-sequence'; + + const inferredMediaType = + mediaType === 'video' || mediaType === 'audio' + ? mediaType + : inferEditorAssetMediaType(imageSrc, objectKey); + if (inferredMediaType === 'video') { + return 'video'; } - if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') { - return snapshot.mediaType; + if (inferredMediaType === 'audio') { + return inferAudioAssetKindFromLabel(label); } return 'image'; } +function hasMediaFileExtension( + source: string | null | undefined, + extension: string, +) { + const normalizedSource = source?.trim().toLowerCase() ?? ''; + const sourceWithoutQuery = normalizedSource.split(/[?#]/u)[0] ?? ''; + return sourceWithoutQuery.endsWith(extension); +} + function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { - return label.includes('背景音乐') || label.toLowerCase().includes('music') - ? 'background-music' - : 'sound-effect'; + if (label.includes('背景音乐') || label.toLowerCase().includes('music')) { + return 'background-music'; + } + return 'sound-effect'; } export type CanvasLayerResourceMetadata = { @@ -871,7 +950,7 @@ export type CanvasLayerResourceMetadata = { width?: number | null; height?: number | null; sourceType?: string | null; - durationSeconds?: number | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; prompt?: string | null; @@ -880,6 +959,7 @@ export type CanvasLayerResourceMetadata = { provider?: string | null; taskId?: string | null; generationInputs?: unknown; + imageSequenceFrames?: unknown; createdAt?: string | null; updatedAt?: string | null; }; @@ -1002,7 +1082,7 @@ function hydrateImageSequenceFrames( if (!Array.isArray(value)) { return []; } - const frames = value.flatMap((frame, index) => { + const frames = value.flatMap((frame) => { if (!frame || typeof frame !== 'object') { return []; } @@ -1013,7 +1093,6 @@ function hydrateImageSequenceFrames( } return [ { - frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1), imageSrc, objectKey: stringOrUndefined(snapshot.objectKey), assetObjectId: stringOrUndefined(snapshot.assetObjectId), @@ -1149,7 +1228,9 @@ export function generationInputsOrNull( } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { - return value === 'spec' || + return value === 'image' || + value === 'audio' || + value === 'spec' || value === 'character' || value === 'character-animation' || value === 'icon' || diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 64b479ef9..ff6795de0 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -1,11 +1,11 @@ import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, - EditorCharacterAnimationFrameResult, EditorCharacterAnimationGenerationResult, EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, EditorImageGenerationStyle, + EditorImageSequenceFrameResult, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -15,6 +15,8 @@ import type { export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated'; export type CanvasAssetKind = + | 'image' + | 'audio' | 'spec' | 'character' | 'character-animation' @@ -58,7 +60,8 @@ export type EditorAsset = { showcaseSubmitError?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; - durationSeconds?: number; + imageSequenceFrames?: EditorImageSequenceFrameResult[]; + imageSequenceDurationMs?: number; uploadStatus?: 'uploading' | 'failed'; uploadProgress?: number; uploadMessage?: string; @@ -110,7 +113,8 @@ export type CanvasLayer = { assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; - imageSequenceFrames?: EditorCharacterAnimationFrameResult[]; + imageSequenceFrames?: EditorImageSequenceFrameResult[]; + imageSequenceDurationMs?: number; previewVideoPath?: string | null; hidden?: boolean; locked?: boolean; diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 2fee555f1..97e34c81f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -267,6 +267,16 @@ export function setupImageCanvasEditorViewTestLifecycle({ immediateAsync(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '默认项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultEditorProjectLayers, + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, @@ -299,7 +309,8 @@ export function setupImageCanvasEditorViewTestLifecycle({ sourceType: input.sourceType, assetKind: input.assetKind, generationInputs: input.generationInputs, - durationSeconds: input.durationSeconds, + imageSequenceFrames: input.imageSequenceFrames, + imageSequenceDurationMs: input.imageSequenceDurationMs, })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', @@ -353,12 +364,20 @@ export function setupImageCanvasEditorViewTestLifecycle({ width: input.width, height: input.height, sourceType: input.sourceType, - durationSeconds: input.durationSeconds, assetKind: input.assetKind, generationInputs: input.generationInputs, + imageSequenceFrames: input.imageSequenceFrames, + imageSequenceDurationMs: input.imageSequenceDurationMs, + }), + ); + saveEditorProjectLayoutMock.mockImplementation( + async (projectId, input) => ({ + projectId, + canvasId: `${projectId}:canvas:default`, + revision: input.expectedRevision + 1, + updatedAt: '2026-06-12T00:00:01.000Z', }), ); - saveEditorProjectLayoutMock.mockResolvedValue({}); }); afterEach(() => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ce516ccd9..8461bcff3 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -289,7 +289,8 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { sourceAssetId: asset.id, assetKind: asset.assetKind ?? null, generationInputs: asset.generationInputs ?? null, - durationSeconds: asset.durationSeconds, + imageSequenceFrames: asset.imageSequenceFrames, + imageSequenceDurationMs: asset.imageSequenceDurationMs, }; } @@ -1003,11 +1004,22 @@ export function ImageCanvasEditorView({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, assetKind: layer.assetKind, generationInputs: layer.generationInputs, }) .then((asset) => { + const persistedAssetKind = canvasAssetKindOrNull(asset.assetKind); + if ( + layer.assetKind === 'character-animation' && + (persistedAssetKind !== 'character-animation' || + (asset.imageSequenceFrames?.length ?? 0) < 2 || + !(asset.imageSequenceDurationMs && + asset.imageSequenceDurationMs > 0)) + ) { + throw new Error('服务器未返回完整的角色动作正式字段'); + } setAssets((currentAssets) => [ ...currentAssets.filter( (currentAsset) => currentAsset.id !== asset.assetId, @@ -1030,8 +1042,10 @@ export function ImageCanvasEditorView({ taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, - durationSeconds: asset.durationSeconds ?? layer.durationSeconds, - assetKind: canvasAssetKindOrNull(asset.assetKind), + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: + asset.imageSequenceDurationMs ?? undefined, + assetKind: persistedAssetKind, generationInputs: generationInputsOrNull(asset.generationInputs), }, ]); @@ -1095,7 +1109,8 @@ export function ImageCanvasEditorView({ objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, thumbnailSrc: asset.thumbnailSrc ?? undefined, - durationSeconds: asset.durationSeconds ?? undefined, + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), }, @@ -1314,7 +1329,8 @@ export function ImageCanvasEditorView({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -1381,6 +1397,7 @@ export function ImageCanvasEditorView({ isExportingAssets, exportCanvasAssets, exportLayerImage, + reportAssetError, } = useImageCanvasAssetExportWorkflow({ layers, projectId, @@ -2214,6 +2231,7 @@ export function ImageCanvasEditorView({ appendCanvasLayersWithResources, selectSingleLayer, addUploadedFiles, + onAssetError: reportAssetError, }); deleteLayerByIdRef.current = deleteLayerById; diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6a3eeeb06..fbfab01a1 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -98,7 +98,6 @@ describe('ImageCanvasExportModel', () => { taskId: 'sequence-task', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, @@ -217,15 +216,15 @@ describe('ImageCanvasExportModel', () => { mediaType: 'image-sequence', originalWidth: 192, originalHeight: 256, - durationSeconds: 0.5, + imageSequenceDurationMs: 500, }); const spineJson = buildSpineImageSequenceJson({ layer, frames: [ - { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, - { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, - { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, - { frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 }, + { fileName: 'frame-01.png', width: 192, height: 256 }, + { fileName: 'frame-02.png', width: 192, height: 256 }, + { fileName: 'frame-03.png', width: 192, height: 256 }, + { fileName: 'frame-04.png', width: 192, height: 256 }, ], }); @@ -311,7 +310,6 @@ describe('ImageCanvasExportModel', () => { mediaType: 'image-sequence', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-editor-frames/frame-1.png', objectKey: 'generated/frame-1.png', width: 512, @@ -320,7 +318,6 @@ describe('ImageCanvasExportModel', () => { ], }), { - frameIndex: 1, imageSrc: '/generated-editor-frames/frame-1.png', objectKey: 'generated/frame-1.png', width: 512, diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 3c064986d..3b9d4233d 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -173,20 +173,7 @@ export async function readLayerAssetBlob(layer: CanvasLayer) { export const readLayerImageBlob = readLayerAssetBlob; export function getLayerImageSequenceFrames(layer: CanvasLayer) { - if (layer.imageSequenceFrames?.length) { - return layer.imageSequenceFrames; - } - const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); - return fallbackSrc - ? [ - { - frameIndex: 1, - imageSrc: fallbackSrc, - width: layer.originalWidth, - height: layer.originalHeight, - }, - ] - : []; + return layer.imageSequenceFrames ?? []; } export async function readLayerImageSequenceFrameBlob( @@ -196,7 +183,7 @@ export async function readLayerImageSequenceFrameBlob( return readAssetSourceBlob({ source: frame.imageSrc, objectKey: frame.objectKey, - refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.objectKey ?? frame.imageSrc}`, }); } @@ -206,12 +193,11 @@ export function getImageSequenceFrameFileName( blobType = '', ) { const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc); - const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0'); + const frameNumber = String(index + 1).padStart(2, '0'); return `frame-${frameNumber}.${extension}`; } export type ExportedImageSequenceFrame = { - frameIndex: number; fileName: string; width: number; height: number; @@ -417,8 +403,9 @@ export function buildSpineImageSequenceJson({ } const durationSeconds = - typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 - ? layer.durationSeconds + typeof layer.imageSequenceDurationMs === 'number' && + layer.imageSequenceDurationMs > 0 + ? layer.imageSequenceDurationMs / 1_000 : frames.length; const frameDuration = durationSeconds / frames.length; const skeletonWidth = frames[0]?.width || layer.originalWidth; diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 248cbd2c9..790d4a886 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -589,24 +589,40 @@ describe('ImageCanvasGenerationLayerModel', () => { }); it('creates character animation result layers as playable image sequences', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/frame1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/frame2.png', + width: 1024, + height: 1024, + }, + ]; const layer = createCharacterAnimationResultLayer({ generated: { taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '待机动作', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', - frames: [ - { - frameIndex: 1, - imageSrc: '/generated-character-drafts/editor/frame1.png', - width: 1024, - height: 1024, - }, - ], - frameCount: 32, + frames, + frameCount: 2, durationSeconds: 4, fps: 8, priceMudPoints: 40, + resource: { + resourceId: 'resource-character-animation-15', + projectId: 'project-1', + imageSrc: frames[0]!.imageSrc, + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, }, generatedIndex: 15, title: '角色动作', @@ -628,20 +644,13 @@ describe('ImageCanvasGenerationLayerModel', () => { expect(layer).toMatchObject({ id: 'layer-character-animation-15', - resourceId: 'local-resource-character-animation-15', + resourceId: 'resource-character-animation-15', title: '角色动作', src: '/generated-character-drafts/editor/frame1.png', mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame1.png', - imageSequenceFrames: [ - { - frameIndex: 1, - imageSrc: '/generated-character-drafts/editor/frame1.png', - width: 1024, - height: 1024, - }, - ], - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + imageSequenceFrames: frames, + previewVideoPath: null, x: -222, y: -242, width: 1024, @@ -652,12 +661,76 @@ describe('ImageCanvasGenerationLayerModel', () => { sourceType: 'generated', prompt: '待机动作', actualPrompt: '待机动作', + imageSequenceDurationMs: 4_000, model: 'seedance2.0-fast', provider: 'ark', taskId: 'character-animation-task-1', assetKind: 'character-animation', - durationSeconds: 4, }); expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作'); }); + + it('links a character animation result layer to its persisted project resource', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/frame1.png', + objectKey: 'generated-character-drafts/editor/frame1.png', + assetObjectId: 'asset-object-frame-1', + width: 512, + height: 768, + }, + { + imageSrc: '/generated-character-drafts/editor/frame2.png', + objectKey: 'generated-character-drafts/editor/frame2.png', + assetObjectId: 'asset-object-frame-2', + width: 512, + height: 768, + }, + ]; + const layer = createCharacterAnimationResultLayer({ + generated: { + taskId: 'character-animation-task-persisted', + model: 'seedance2.0-fast', + prompt: '奔跑动作', + previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + frames, + frameCount: 2, + durationSeconds: 4, + fps: 8, + priceMudPoints: 40, + resource: { + resourceId: 'resource-character-animation-final', + projectId: 'project-1', + imageSrc: frames[0]!.imageSrc, + objectKey: frames[0]!.objectKey, + assetObjectId: frames[0]!.assetObjectId, + width: 512, + height: 768, + sourceType: 'generated', + sourceResourceId: 'resource-character-animation-preview', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + }, + generatedIndex: 16, + title: '角色奔跑', + canvasSize: { width: 900, height: 640 }, + viewport: { x: 0, y: 0, scale: 1 }, + generationInputs: createGenerationInputs(), + sourceLayer: createSourceLayer({ + resourceId: 'resource-original-character', + }), + }); + + expect(layer).toMatchObject({ + resourceId: 'resource-character-animation-final', + src: frames[0]!.imageSrc, + objectKey: frames[0]!.objectKey, + assetObjectId: frames[0]!.assetObjectId, + sourceResourceId: 'resource-character-animation-preview', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 612678189..8d45623a7 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -534,8 +534,17 @@ export function createCharacterAnimationResultLayer({ frame, sourceLayer, }: CharacterAnimationResultLayerOptions): CanvasLayer | null { - const previewFrame = generated.frames[0]; - if (!previewFrame) { + const resource = generated.resource ?? undefined; + const asset = generated.asset ?? undefined; + const persisted = resource ?? asset; + const imageSequenceFrames = persisted?.imageSequenceFrames ?? []; + const imageSequenceDurationMs = persisted?.imageSequenceDurationMs; + const previewFrame = imageSequenceFrames[0]; + if ( + imageSequenceFrames.length < 2 || + !previewFrame || + !(imageSequenceDurationMs && imageSequenceDurationMs > 0) + ) { return null; } const originalWidth = previewFrame.width || 1024; @@ -557,13 +566,15 @@ export function createCharacterAnimationResultLayer({ return { id: `layer-character-animation-${generatedIndex}`, - resourceId: `local-resource-character-animation-${generatedIndex}`, + resourceId: + resource?.resourceId ?? + `local-resource-${asset?.assetId ?? `character-animation-${generatedIndex}`}`, title, - src: previewFrame.imageSrc, + src: persisted?.imageSrc ?? previewFrame.imageSrc, mediaType: 'image-sequence', thumbnailSrc: previewFrame.imageSrc, - imageSequenceFrames: generated.frames, - previewVideoPath: generated.previewVideoPath, + imageSequenceFrames, + previewVideoPath: null, x: frameX ?? worldCenter.x - width / 2, y: frameY ?? worldCenter.y - height / 2, width, @@ -572,15 +583,19 @@ export function createCharacterAnimationResultLayer({ originalHeight, zIndex: generatedIndex + 10, sourceType: 'generated', - prompt: generated.prompt, - actualPrompt: generated.prompt, - model: generated.model, - provider: 'ark', - taskId: generated.taskId, + prompt: persisted?.prompt ?? generated.prompt, + actualPrompt: persisted?.actualPrompt ?? generated.prompt, + model: persisted?.model ?? generated.model, + provider: persisted?.provider ?? 'ark', + taskId: persisted?.taskId ?? generated.taskId, assetKind: 'character-animation', - durationSeconds: generated.durationSeconds, - sourceResourceId: sourceLayer?.resourceId, + imageSequenceDurationMs, + objectKey: persisted?.objectKey, + assetObjectId: persisted?.assetObjectId, + sourceAssetId: asset?.assetId, + sourceResourceId: resource?.sourceResourceId ?? sourceLayer?.resourceId, groupId: sourceLayer?.groupId, - generationInputs, + generationInputs: persisted?.generationInputs ?? generationInputs, + generatedAssetSnapshot: asset, }; } diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c2e21ecbf..62b3f5beb 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -504,8 +504,21 @@ describe('ImageCanvasGenerationModel', () => { mediaType: 'image-sequence', src: '/generated-character-drafts/editor/frame01.png', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', + imageSequenceFrames: [ + { + imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-character-drafts/editor/frame02.png', + width: 512, + height: 512, + }, + ], }), - ).toBe('/generated-character-drafts/editor/frame01.png'); + ).toBe('generated-character-drafts/editor/frame01.png'); }); it('maps generation dialog mode and authorization errors to user-facing copy', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 463def180..4ec162fb7 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -901,8 +901,13 @@ function cloneModelPricing( } export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { - if (layer.assetKind === 'character-animation' && layer.thumbnailSrc?.trim()) { - return layer.thumbnailSrc.trim(); + if (layer.assetKind === 'character-animation') { + const firstFrame = layer.imageSequenceFrames?.[0]; + const source = firstFrame?.objectKey?.trim() || firstFrame?.imageSrc?.trim(); + if (!source) { + throw new Error('角色动作缺少正式序列首帧,无法继续生成。'); + } + return source; } // 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。 return layer.objectKey?.trim() || layer.src; diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index 25401a6f4..bd7b12a85 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -254,7 +254,6 @@ describe('ImageCanvasHistoryModel', () => { it('blocks replacing an image sequence frame', () => { const frame = { - frameIndex: 0, imageSrc: '/frames/0.png', objectKey: 'frames/0.png', width: 100, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index 8ace4aed9..ef28ce609 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -94,7 +94,6 @@ function hasSameImageSequenceFrames( const targetFrame = targetFrames[index]; return ( targetFrame !== undefined && - currentFrame.frameIndex === targetFrame.frameIndex && hasSameStableMediaReference( { src: currentFrame.imageSrc, @@ -118,7 +117,8 @@ function hasSameLayerContent( return ( (current.mediaType ?? 'image') === (target.mediaType ?? 'image') && hasSameStableMediaReference(current, target) && - hasSameImageSequenceFrames(current, target) + hasSameImageSequenceFrames(current, target) && + current.imageSequenceDurationMs === target.imageSequenceDurationMs ); } diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 805f0d367..fe1acf025 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -112,7 +112,6 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { mediaType: 'image-sequence', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated/frame-1.png', objectKey: 'generated/frame-1.png', width: 320, diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index 4b4aabba3..08ee58437 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -117,9 +117,10 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), - cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`, + cacheVersion: `${layer.taskId ?? layer.resourceId}:0`, }; } + return null; } const snapshotSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); if (!snapshotSrc) { diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index fed0c5e1d..d87f810c9 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -11,6 +11,7 @@ import type { QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; +import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; @@ -25,7 +26,6 @@ type PersistedUploadAsset = { height: number; objectKey?: string | null; assetObjectId?: string | null; - durationSeconds?: number | null; }; type GenerationReferenceUploadTarget = | 'character-reference' @@ -299,14 +299,12 @@ export function createUploadingAssetPlaceholder({ folderId, mediaType = 'image', imageDimensions, - durationSeconds, }: { uploadIndex: number; fileName?: string; folderId: string; mediaType?: CanvasMediaType; imageDimensions?: UploadImageDimensions; - durationSeconds?: number; }): EditorAsset { const fallbackSize = resolveUploadFallbackSize(mediaType); const size = @@ -334,7 +332,7 @@ export function createUploadingAssetPlaceholder({ sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, - durationSeconds, + assetKind: resolveUploadedAssetKind(mediaType), uploadStatus: 'uploading', uploadProgress: 8, uploadMessage: '准备上传', @@ -438,8 +436,6 @@ export function applyPersistedUploadAsset({ height: persistedAsset.height, objectKey: persistedAsset.objectKey ?? undefined, assetObjectId: persistedAsset.assetObjectId ?? undefined, - durationSeconds: - persistedAsset.durationSeconds ?? asset.durationSeconds, persisted: true, uploadStatus: undefined, uploadProgress: undefined, @@ -510,8 +506,6 @@ export function bindUploadLayerToPersistedAsset({ mediaType: persistedAsset.mediaType ?? layer.mediaType, objectKey: persistedAsset.objectKey ?? layer.objectKey, assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId, - durationSeconds: - persistedAsset.durationSeconds ?? layer.durationSeconds, } : layer, ); @@ -585,12 +579,7 @@ export function createUploadCanvasLayer({ : '上传图片'), src: imageSrc, mediaType, - assetKind: - mediaType === 'video' - ? 'video' - : mediaType === 'audio' - ? 'sound-effect' - : undefined, + assetKind: resolveUploadedAssetKind(mediaType), x: worldCenterX - layerSize.width / 2, y: worldCenterY - layerSize.height / 2, width: layerSize.width, diff --git a/src/components/image-editor/ImageCanvasUploadUtils.ts b/src/components/image-editor/ImageCanvasUploadUtils.ts new file mode 100644 index 000000000..da6b56967 --- /dev/null +++ b/src/components/image-editor/ImageCanvasUploadUtils.ts @@ -0,0 +1,16 @@ +import type { + CanvasAssetKind, + CanvasMediaType, +} from './ImageCanvasEditorTypes'; + +export function resolveUploadedAssetKind( + mediaType: CanvasMediaType, +): CanvasAssetKind { + switch (mediaType) { + case 'video': + return 'video'; + case 'audio': + return 'audio'; + } + return 'image'; +} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 446a3fa26..a32b06b31 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -225,12 +225,13 @@ describe('ImageCanvasWorldView', () => { it('resolves image layers from objectKey before falling back to src', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ - resolvedUrl: - options?.objectKey === 'generated-character-drafts/editor/uploaded.png' - ? 'https://oss.example.com/uploaded.png?signature=1' - : _source, - isResolving: false, - shouldResolve: true, + resolvedUrl: + options?.objectKey === + 'generated-character-drafts/editor/uploaded.png' + ? 'https://oss.example.com/uploaded.png?signature=1' + : _source, + isResolving: false, + shouldResolve: true, }), ); @@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('生成中')).toBeTruthy(); const world = document.querySelector('.image-canvas-editor__world'); - const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + const marquee = world?.querySelector( + '.image-canvas-editor__canvas-marquee', + ); expect(world?.getAttribute('style')).toContain( 'transform: translate(10px, 20px) scale(1.5)', @@ -530,7 +533,14 @@ describe('ImageCanvasWorldView', () => { }, ])( 'renders $mode generation placeholder with its own icon and badge', - ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => { + ({ + mode, + ariaLabel, + generatorLabel, + badgeLabel, + frameClassName, + iconClassName, + }) => { const dialog = createGenerationDialog({ id: `dialog-${mode}`, mode: mode as CanvasGenerationDialogState['mode'], @@ -614,9 +624,9 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(generatorLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( mode === 'audio-sound-effect' || mode === 'audio-background-music' @@ -630,9 +640,9 @@ describe('ImageCanvasWorldView', () => { : inverseScale, ); expect( - (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(badgeLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }, ); @@ -718,14 +728,14 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('角色') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('640 x 480 px') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( within(layerButton) @@ -733,14 +743,14 @@ describe('ImageCanvasWorldView', () => { .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('Image Generator') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('1024 x 768') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }); @@ -755,8 +765,11 @@ describe('ImageCanvasWorldView', () => { const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect( - (frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ( + frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe('4'); }); @@ -813,9 +826,9 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain( - 'lucide-info', - ); + expect( + metadataButton.querySelector('svg')?.getAttribute('class'), + ).toContain('lucide-info'); }); it('renders audio layers with an audio control card instead of an image', () => { @@ -849,7 +862,8 @@ describe('ImageCanvasWorldView', () => { within(layerButton).getAllByRole('button', { name: /播放|暂停/u }), ).toHaveLength(1); expect( - within(layerButton).getByRole('button', { name: '播放游戏音效' }) + within(layerButton) + .getByRole('button', { name: '播放游戏音效' }) .querySelector('svg') ?.getAttribute('class'), ).toContain('lucide-volume-2'); @@ -872,9 +886,7 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy(); expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy(); - expect( - within(layerButton).queryByText('420 x 120 px'), - ).toBeNull(); + expect(within(layerButton).queryByText('420 x 120 px')).toBeNull(); }); it('keeps audio controls interactive and switches icon, play and pause states', () => { @@ -895,7 +907,9 @@ describe('ImageCanvasWorldView', () => { selectedLayerIds: [layer.id], }); - const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' }); + const layerButton = screen.getByRole('button', { + name: '选择游戏背景音乐', + }); const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐'); const playbackButton = within(layerButton).getByRole('button', { name: '播放游戏背景音乐', @@ -932,9 +946,8 @@ describe('ImageCanvasWorldView', () => { ?.getAttribute('class'), ).toContain('lucide-play'); - const hoveredAudio = within(hoveredLayerButton).getByLabelText( - '画布音频:游戏背景音乐', - ); + const hoveredAudio = + within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐'); fireEvent.play(hoveredAudio); expect( within(hoveredLayerButton) @@ -1032,9 +1045,7 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); fireEvent.pointerDown(document.body); - expect( - screen.queryByRole('menu', { name: '选择素材标签' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull(); } finally { document.removeEventListener('wheel', documentWheel); } @@ -1063,7 +1074,7 @@ describe('ImageCanvasWorldView', () => { ? 'https://oss.example.com/video-7.mp4?signature=1' : source === '/generated-character-drafts/editor/video-7-poster.png' ? 'https://oss.example.com/video-7-poster.png?signature=1' - : source, + : source, isResolving: false, shouldResolve: true, })); @@ -1100,7 +1111,9 @@ describe('ImageCanvasWorldView', () => { layerButton.querySelector('.image-canvas-editor__media-preview--video') ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); - expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull(); + expect( + within(layerButton).queryByAltText('画布视频:生成视频 7'), + ).toBeNull(); expect(within(layerButton).getByText('视频')).toBeTruthy(); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), @@ -1153,19 +1166,17 @@ describe('ImageCanvasWorldView', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }); renderWorldView({ layers: [layer] }); @@ -1201,17 +1212,15 @@ describe('ImageCanvasWorldView', () => { it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { vi.useFakeTimers(); try { - useResolvedAssetReadUrlMock.mockImplementation( - (source: string) => ({ - resolvedUrl: - source === '/generated-character-drafts/editor/frame01.png' - ? 'https://oss.example.com/frame01.png?signature=1' - : '', - isResolving: - source === '/generated-character-drafts/editor/frame02.png', - shouldResolve: true, - }), - ); + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ + resolvedUrl: + source === '/generated-character-drafts/editor/frame01.png' + ? 'https://oss.example.com/frame01.png?signature=1' + : '', + isResolving: + source === '/generated-character-drafts/editor/frame02.png', + shouldResolve: true, + })); const layer = createLayer({ title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', @@ -1220,29 +1229,26 @@ describe('ImageCanvasWorldView', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], - durationSeconds: 0.1, + imageSequenceDurationMs: 100, }); renderWorldView({ layers: [layer] }); const layerButton = screen.getByRole('button', { name: '选择角色动作' }); - const firstFrame = within(layerButton).getByAltText( - '画布序列帧:角色动作', - ); + const firstFrame = + within(layerButton).getByAltText('画布序列帧:角色动作'); fireEvent.load(firstFrame); expect( @@ -1292,8 +1298,6 @@ describe('ImageCanvasWorldView', () => { }); expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); - expect( - screen.queryByRole('button', { name: '图像生成占位图' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 97ef196df..783b7f2e4 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -691,20 +691,7 @@ function ImageCanvasImageLayer({ } function getLayerImageSequenceFrames(layer: CanvasLayer) { - if (layer.imageSequenceFrames?.length) { - return layer.imageSequenceFrames; - } - const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); - return fallbackSrc - ? [ - { - frameIndex: 1, - imageSrc: fallbackSrc, - width: layer.originalWidth, - height: layer.originalHeight, - }, - ] - : []; + return layer.imageSequenceFrames ?? []; } function getImageSequenceFrameKey( @@ -715,7 +702,6 @@ function getImageSequenceFrameKey( return [ layer.id, layer.taskId ?? layer.resourceId ?? '', - frame.frameIndex, frame.objectKey ?? '', frame.imageSrc, index, @@ -741,7 +727,7 @@ function ImageCanvasImageSequenceFrame({ }) { const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { objectKey: frame.objectKey, - refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frameKey}`, }); const frameImageRef = useRef(null); @@ -841,19 +827,17 @@ function ImageCanvasImageSequenceLayer({ if (!isPlaying || frames.length <= 1) { return undefined; } - const durationSeconds = - typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 - ? layer.durationSeconds - : frames.length / 8; - const intervalMs = Math.max( - 40, - Math.round((durationSeconds * 1000) / frames.length), - ); + const durationMs = + typeof layer.imageSequenceDurationMs === 'number' && + layer.imageSequenceDurationMs > 0 + ? layer.imageSequenceDurationMs + : (frames.length / 8) * 1_000; + const intervalMs = Math.max(40, Math.round(durationMs / frames.length)); const timer = window.setInterval(() => { setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); }, intervalMs); return () => window.clearInterval(timer); - }, [frames.length, isPlaying, layer.durationSeconds]); + }, [frames.length, isPlaying, layer.imageSequenceDurationMs]); return ( <> @@ -1003,176 +987,176 @@ export function ImageCanvasWorldView({ ) : null} {visibleLayers.map((layer) => { - const isSelected = selectedLayerIds.includes(layer.id); - const isHovered = hoveredLayerId === layer.id; - const kindTagMeta = getLayerKindTagMeta(layer); - const kindLabel = kindTagMeta.label; - const mediaTransform = - layer.flipX || layer.flipY - ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` - : undefined; - const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( - layer.id, - ) - ? '拆图中' - : generateDialog?.mode === 'edit' && - generateDialog.status === 'generating' && - generateDialog.sourceLayerId === layer.id - ? '修改中' - : null; - const isMediaContainer = isCanvasMediaContainerLayer(layer); - const LayerElement = isMediaContainer ? 'div' : 'button'; - const metadataMediaLabel = getLayerMetadataMediaLabel(layer); - return ( -
- onLayerPointerDown(event, layer)} - onClick={(event) => { - setOpenLayerKindMenuId(null); - onLayerClick(event, layer); - }} - onContextMenu={(event) => { - setOpenLayerKindMenuId(null); - onLayerContextMenu(event, layer); - }} - onKeyDown={ - isMediaContainer ? handleLayerKeyboardActivation : undefined - } - onMouseEnter={() => onLayerMouseEnter(layer.id)} - onMouseLeave={() => onLayerMouseLeave(layer.id)} - aria-label={`选择${layer.title}`} - > - {layer.mediaType === 'audio' ? ( - onSelectLayer(layer.id)} - /> - ) : layer.mediaType === 'video' ? ( - - ) : layer.mediaType === 'image-sequence' ? ( - - ) : ( - - )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + const isSelected = selectedLayerIds.includes(layer.id); + const isHovered = hoveredLayerId === layer.id; + const kindTagMeta = getLayerKindTagMeta(layer); + const kindLabel = kindTagMeta.label; + const mediaTransform = + layer.flipX || layer.flipY + ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` + : undefined; + const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( + layer.id, + ) + ? '拆图中' + : generateDialog?.mode === 'edit' && + generateDialog.status === 'generating' && + generateDialog.sourceLayerId === layer.id + ? '修改中' + : null; + const isMediaContainer = isCanvasMediaContainerLayer(layer); + const LayerElement = isMediaContainer ? 'div' : 'button'; + const metadataMediaLabel = getLayerMetadataMediaLabel(layer); + return ( +
+ onLayerPointerDown(event, layer)} + onClick={(event) => { + setOpenLayerKindMenuId(null); + onLayerClick(event, layer); + }} + onContextMenu={(event) => { + setOpenLayerKindMenuId(null); + onLayerContextMenu(event, layer); + }} + onKeyDown={ + isMediaContainer ? handleLayerKeyboardActivation : undefined + } + onMouseEnter={() => onLayerMouseEnter(layer.id)} + onMouseLeave={() => onLayerMouseLeave(layer.id)} + aria-label={`选择${layer.title}`} + > + {layer.mediaType === 'audio' ? ( + onSelectLayer(layer.id)} + /> + ) : layer.mediaType === 'video' ? ( + + ) : layer.mediaType === 'image-sequence' ? ( + + ) : ( + + )} + {kindLabel ? ( + event.stopPropagation()} onClick={(event) => { event.stopPropagation(); - onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} - /> - {isHovered && layer.mediaType !== 'audio' ? ( - - {getLayerMetricLabel(layer)} - - ) : null} - {layerGeneratingLabel ? ( - - {layerGeneratingLabel} - - ) : null} - - {openLayerKindMenuId === layer.id ? ( -
+ currentId === layer.id ? null : layer.id, + ); }} + onKeyDown={handleLayerTagKeyboardActivation} > - event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( - { - event.stopPropagation(); - onUpdateLayerAssetKind(layer.id, option.value); - setOpenLayerKindMenuId(null); - }} - > - {option.label} - - ))} - -
+ {kindLabel} + ) : null} -
- ); - })} + } + style={inverseScaleStyle} + onClick={(event) => { + event.stopPropagation(); + onOpenLayerMetadata(layer); + }} + onPointerDown={(event) => event.stopPropagation()} + /> + {isHovered && layer.mediaType !== 'audio' ? ( + + {getLayerMetricLabel(layer)} + + ) : null} + {layerGeneratingLabel ? ( + + {layerGeneratingLabel} + + ) : null} +
+ {openLayerKindMenuId === layer.id ? ( +
+ event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( + { + event.stopPropagation(); + onUpdateLayerAssetKind(layer.id, option.value); + setOpenLayerKindMenuId(null); + }} + > + {option.label} + + ))} + +
+ ) : null} +
+ ); + })} {canvasMarquee ? (
void; + onAssetError?: (message: string) => void; }; export function useImageCanvasAssetLayerCleanup({ @@ -111,13 +112,13 @@ export function useImageCanvasAssetLayerCleanup({ } const currentLayer = layers.find((layer) => layer.id === currentId); return currentLayer && - deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + deletedAssets.some((asset) => + isLayerLinkedToAsset(currentLayer, asset), + ) ? null : currentId; }); - deletedLayerIds.forEach((layerId) => - onDeleteLayerSideEffects?.(layerId), - ); + deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId)); }, [ discardHistoryEntriesContainingLayer, @@ -151,21 +152,31 @@ export function useImageCanvasAssetCanvasBridge({ appendCanvasLayersWithResources, selectSingleLayer, addUploadedFiles, + onAssetError, }: UseImageCanvasAssetCanvasBridgeOptions) { const addAssetLayer = useCallback( (asset: EditorAsset, position?: CanvasPoint) => { setActiveUploadFolderId(asset.folderId); - layerCounterRef.current += 1; - const nextLayer = createLayerFromAsset( - asset, - layerCounterRef.current, - viewport, - { - x: position?.x ?? canvasSize.width / 2, - y: position?.y ?? canvasSize.height / 2, - }, - { applyCascadeOffset: position === undefined }, - ); + const nextLayerIndex = layerCounterRef.current + 1; + let nextLayer: CanvasLayer; + try { + nextLayer = createLayerFromAsset( + asset, + nextLayerIndex, + viewport, + { + x: position?.x ?? canvasSize.width / 2, + y: position?.y ?? canvasSize.height / 2, + }, + { applyCascadeOffset: position === undefined }, + ); + } catch (error) { + onAssetError?.( + error instanceof Error ? error.message : '角色动作素材不完整。', + ); + return; + } + layerCounterRef.current = nextLayerIndex; captureCanvasHistory({ type: 'add-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); @@ -177,6 +188,7 @@ export function useImageCanvasAssetCanvasBridge({ canvasSize.width, captureCanvasHistory, layerCounterRef, + onAssetError, selectSingleLayer, setActiveUploadFolderId, setHoveredLayerId, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 3fa2ee919..a947a250c 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -196,19 +196,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, previewVideoPath: '/generated-character-drafts/editor/preview.mp4', sourceType: 'generated', @@ -482,19 +480,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }), ]} />, @@ -590,19 +586,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }), ]} />, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index c011b1c54..3441e37d2 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -152,6 +152,9 @@ async function buildImageSequenceZip( if (!frames.length) { throw new Error('序列帧为空'); } + const durationSeconds = layer.imageSequenceDurationMs + ? layer.imageSequenceDurationMs / 1_000 + : undefined; const zip = new JSZip(); const framesFolder = zip.folder('frames') ?? zip; const failedFrames: Array<{ @@ -169,7 +172,6 @@ async function buildImageSequenceZip( const fileName = getImageSequenceFrameFileName(frame, index, blob.type); framesFolder.file(fileName, await blobToUint8Array(blob)); exportedFrames.push({ - frameIndex: frame.frameIndex || index + 1, fileName, width: frame.width, height: frame.height, @@ -177,7 +179,7 @@ async function buildImageSequenceZip( successCount += 1; } catch (error) { failedFrames.push({ - frameIndex: frame.frameIndex || index + 1, + frameIndex: index + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); } @@ -191,7 +193,7 @@ async function buildImageSequenceZip( `素材:${layer.title}`, `类型:${mode === 'spine-json' ? 'Spine JSON' : '序列帧'}`, `帧数:${successCount}/${frames.length}`, - `时长:${layer.durationSeconds ? `${layer.durationSeconds}s` : '0s'}`, + `时长:${durationSeconds ? `${durationSeconds}s` : '0s'}`, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ].filter(Boolean); @@ -203,7 +205,7 @@ async function buildImageSequenceZip( width: layer.width, height: layer.height, }, - layer.durationSeconds, + durationSeconds, ); zip.file('preview.gif', await blobToUint8Array(previewBlob)); } @@ -256,7 +258,7 @@ async function buildImageSequenceZip( `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', - layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, + durationSeconds ? `时长:${durationSeconds}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] .filter(Boolean) @@ -300,9 +302,14 @@ export function useImageCanvasAssetExportWorkflow({ } }) .catch(() => { + const hasUsableFrameSource = + (layer.imageSequenceFrames?.length ?? 0) > 0 && + (layer.imageSequenceDurationMs ?? 0) > 0; setAssetExportStatus({ tone: 'error', - message: '序列帧导出失败', + message: hasUsableFrameSource + ? '序列帧导出失败' + : `角色动作素材“${layer.title}”不完整:缺少可用序列帧。`, }); }); return; @@ -411,7 +418,6 @@ export function useImageCanvasAssetExportWorkflow({ ); framesFolder.file(fileName, await blobToUint8Array(frameBlob)); exportedFrames.push({ - frameIndex: frame.frameIndex || frameArrayIndex + 1, fileName, width: frame.width, height: frame.height, @@ -419,7 +425,7 @@ export function useImageCanvasAssetExportWorkflow({ successCount += 1; } catch (error) { failedFrames.push({ - frameIndex: frame.frameIndex || frameArrayIndex + 1, + frameIndex: frameArrayIndex + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); @@ -463,8 +469,8 @@ export function useImageCanvasAssetExportWorkflow({ `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', - layer.durationSeconds - ? `时长:${layer.durationSeconds}s` + layer.imageSequenceDurationMs + ? `时长:${layer.imageSequenceDurationMs / 1_000}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] @@ -595,10 +601,14 @@ export function useImageCanvasAssetExportWorkflow({ } }, [isExportingAssets, layers, projectTitle]); + const reportAssetError = useCallback((message: string) => { + setAssetExportStatus({ tone: 'error', message }); + }, []); return { assetExportStatus, isExportingAssets, exportCanvasAssets, exportLayerImage, + reportAssetError, }; } diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx index b820c6efc..5a0e52ee8 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -62,6 +62,7 @@ function createAssetSnapshot( height: 240, folderId: 'project', sourceType: 'uploaded', + assetKind: 'image', ...overrides, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 038ecf126..9ce92fd4f 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -322,7 +322,7 @@ function SubmissionWorkflowHarness({ {layers .map( (layer) => - `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`, + `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.imageSequenceDurationMs ?? layer.durationSeconds ?? '-'}`, ) .join('|')} @@ -1365,7 +1365,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'generated-character-drafts/editor/actions/frame.png', width: 1024, height: 1024, @@ -2985,15 +2984,42 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { - frameIndex: 1, - imageSrc: 'data:image/png;base64,action-frame', + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/action-frame-2.png', width: 1024, height: 1024, }, ], - frameCount: 32, + frameCount: 2, durationSeconds: 4, fps: 8, + resource: { + resourceId: 'resource-action-final', + projectId: 'editor-project-1', + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + sourceType: 'generated', + sourceResourceId: 'resource-action-preview', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/action-frame-2.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 4_000, + }, }); render( { }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:勇者挥手:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', + 'layer-character-animation-1:勇者挥手:resource-action-preview:character-animation:image-sequence:/generated-character-drafts/editor/action-frame-1.png:/generated-character-drafts/editor/action-frame-1.png:4000', ); }); expect(screen.getByTestId('dialog').textContent).toBe( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index ff4df781d..adf9b5253 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -2252,6 +2252,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } if (canvasDialog) { + if (projectId && !result.resource) { + throw new Error('角色动作画布完成缺少正式项目资源。'); + } + if (!projectId && !result.asset) { + throw new Error('角色动作生成缺少正式账号素材。'); + } addCharacterAnimationResultLayer( result, submissionPlan.resultTitle, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index e40b1e752..3865beeec 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -174,7 +174,8 @@ function createProjectResourceSnapshotFromLayer( model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index f19245d95..45ac6326e 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -332,7 +332,8 @@ function createEditorProjectSessionCacheResource({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -946,7 +947,8 @@ export function useImageCanvasProjectPersistence({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -1095,7 +1097,8 @@ export function useImageCanvasProjectPersistence({ model: resource.model, provider: resource.provider, taskId: resource.taskId, - durationSeconds: resource.durationSeconds, + imageSequenceFrames: resource.imageSequenceFrames, + imageSequenceDurationMs: resource.imageSequenceDurationMs, sourceResourceId: resource.sourceResourceId, assetKind: resource.assetKind, generationInputs: resource.generationInputs, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index 30e2b669c..e9fef5455 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -409,7 +409,6 @@ describe('useImageCanvasUploadWorkflow', () => { sourceType: input.sourceType, objectKey: input.objectKey, assetObjectId: input.assetObjectId, - durationSeconds: input.durationSeconds, }), ); uploadEditorSeedanceReferenceFileMock.mockImplementation( @@ -643,7 +642,6 @@ describe('useImageCanvasUploadWorkflow', () => { label: '画布视频.mp4', width: 1280, height: 720, - durationSeconds: 4, }), ); }); @@ -849,7 +847,6 @@ describe('useImageCanvasUploadWorkflow', () => { width: 560, height: 315, sourceType: 'uploaded', - durationSeconds: 4, }), ); expect(createEditorAssetMock).not.toHaveBeenCalled(); @@ -891,7 +888,6 @@ describe('useImageCanvasUploadWorkflow', () => { width: 420, height: 120, sourceType: 'uploaded', - durationSeconds: 4, }), ); expect(createEditorAssetMock).not.toHaveBeenCalled(); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index d73d0fecc..b028c99e9 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -63,6 +63,7 @@ import { resizeUploadCanvasLayerToImage, resolveUploadFolderId, } from './ImageCanvasUploadModel'; +import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type UploadFilesOptions = { folderId?: string; @@ -205,6 +206,7 @@ export function useImageCanvasUploadWorkflow({ : null; const width = imageDimensions?.width ?? fallbackSize.width; const height = imageDimensions?.height ?? fallbackSize.height; + const assetKind = resolveUploadedAssetKind(resolvedMediaType); if (projectId) { const resource = await createEditorProjectResource(projectId, { imageSrc: persistedImageSrc, @@ -215,7 +217,7 @@ export function useImageCanvasUploadWorkflow({ width, height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind, }); return createUploadedGenerationReference({ idPrefix, @@ -248,7 +250,7 @@ export function useImageCanvasUploadWorkflow({ width, height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind, }); return createUploadedGenerationReference({ idPrefix, @@ -1044,7 +1046,7 @@ export function useImageCanvasUploadWorkflow({ width: uploadedAsset.width, height: uploadedAsset.height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind: nextLayer.assetKind, }) .then((asset) => { setAssets((currentAssets) => diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index ce9133c08..c37c1e21a 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -236,6 +236,22 @@ describe('editorProjectClient', () => { width: 512, height: 512, sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-editor-assets/public-frame-1.png', + objectKey: 'generated-editor-assets/public-frame-1.png', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-editor-assets/public-frame-2.png', + objectKey: 'generated-editor-assets/public-frame-2.png', + width: 512, + height: 512, + }, + ], + imageSequenceDurationMs: 6_000, publicShowcaseEnabled: true, }, ], @@ -259,6 +275,8 @@ describe('editorProjectClient', () => { expect(page.resources[0]?.ownerUserId).toBe('user-public-author'); expect(page.resources[0]?.authorDisplayName).toBe('公开作者'); expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042'); + expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2); + expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000); expect(page.nextCursor).toBe('cursor-next'); expect(page.campaign?.title).toBe('活动精选'); expect(page.campaign?.imageWidth).toBe(900); @@ -282,7 +300,9 @@ describe('editorProjectClient', () => { nextCursor: null, }); - const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' }); + const page = await listPublicEditorProjectResources({ + cursor: ' cursor-1 ', + }); expect(page.resources).toEqual([]); expect(page.nextCursor).toBeNull(); @@ -428,6 +448,95 @@ describe('editorProjectClient', () => { ); }); + it('passes image sequence duration milliseconds without rounding', async () => { + requestJsonMock + .mockResolvedValueOnce({ + resource: { + resourceId: 'resource-action', + projectId: 'editor-project-1', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceDurationMs: 2_600, + }, + }) + .mockResolvedValueOnce({ + asset: { + assetId: 'asset-action', + folderId: 'project', + label: '角色动作', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceDurationMs: 2_400, + }, + }); + + await createEditorProjectResource('editor-project-1', { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-actions/frame-02.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 2_600, + }); + await createEditorAsset({ + folderId: 'project', + label: '角色动作', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-actions/frame-02.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 2_400, + }); + + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/editor/projects/editor-project-1/resources', + expect.objectContaining({ + body: expect.stringContaining('"imageSequenceDurationMs":2600'), + }), + '创建图片画布资源失败', + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/editor/assets', + expect.objectContaining({ + body: expect.stringContaining('"imageSequenceDurationMs":2400'), + }), + '创建图片画布素材失败', + ); + }); + it('updates project resource showcase visibility', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { @@ -749,8 +858,7 @@ describe('editorProjectClient', () => { taskId: 'vector-task-1', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); @@ -761,8 +869,7 @@ describe('editorProjectClient', () => { expect(result.taskId).toBe('vector-task-1'); expect(result.warning).toEqual({ code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', @@ -812,7 +919,9 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`], + referenceVideoSrcs: [ + `https://assets.example.test/${'a'.repeat(256 * 1024)}`, + ], }), ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); @@ -1295,7 +1404,6 @@ describe('editorProjectClient', () => { previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 76294f98a..8d869104b 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -83,6 +83,7 @@ export type EditorAssetGenerationInputReference = { export type EditorAssetGenerationInputs = { fields: EditorAssetGenerationInputField[]; references: EditorAssetGenerationInputReference[]; + [key: string]: unknown; }; export type EditorProjectResourceSourceType = @@ -110,7 +111,8 @@ export type EditorProjectResourceSnapshot = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; showcaseCategory?: string | null; @@ -151,7 +153,8 @@ export type EditorAssetSnapshot = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; showcaseCategory?: string | null; @@ -392,8 +395,7 @@ export type EditorCharacterAnimationGenerationInput = { assetLabel?: string | null; }; -export type EditorCharacterAnimationFrameResult = { - frameIndex: number; +export type EditorImageSequenceFrameResult = { imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; @@ -406,12 +408,14 @@ export type EditorCharacterAnimationGenerationResult = { model: 'seedance2.0-fast'; prompt: string; previewVideoPath: string; - frames: EditorCharacterAnimationFrameResult[]; + frames: EditorImageSequenceFrameResult[]; frameCount: number; durationSeconds: number; fps: number; priceMudPoints: number; project?: EditorProjectSnapshot | null; + resource?: EditorProjectResourceSnapshot | null; + asset?: EditorAssetSnapshot | null; queueState?: ExternalGenerationJobStatusRecord | null; }; @@ -581,7 +585,8 @@ export type EditorProjectResourceCreateInput = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; @@ -601,7 +606,8 @@ export type EditorAssetCreateInput = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; }; -- 2.52.0 From 00cff24cc1d0a37226cf88c28728d3f8d6961ee6 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:25:38 +0800 Subject: [PATCH 05/42] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=B2=BE=E9=80=89=E4=B8=8E=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 精选与后台素材查询透传正式序列帧和毫秒时长。 列表仅加载首帧,激活预览后再逐帧换签并按完整时长播放。 创作首页与后台复用各自现有媒体组件,损坏动作不回退普通图片。 补齐精选模型、公开卡片和后台审核预览测试。 --- apps/admin-web/src/api/adminApiTypes.ts | 12 + .../src/components/AdminEditorAssetMedia.tsx | 237 +++++++++++++++++- .../pages/AdminEditorAssetQueryPage.test.tsx | 68 +++-- .../AdminEditorShowcaseReviewPage.test.tsx | 95 +++++++ apps/admin-web/src/styles/admin.css | 48 ++++ .../CreationLandingView.test.tsx | 94 ++++++- .../creation-home/CreationLandingView.tsx | 224 ++++++++++++++++- .../creationShowcaseModel.test.ts | 151 ++++++++--- .../creation-home/creationShowcaseModel.ts | 99 +++++++- src/index.css | 40 +++ 10 files changed, 1000 insertions(+), 68 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 88933171a..f590021f3 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -332,6 +332,14 @@ export interface AdminEditorAssetListQuery { limit?: number | null; } +export interface AdminEditorImageSequenceFramePayload { + imageSrc: string; + objectKey?: string | null; + assetObjectId?: string | null; + width: number; + height: number; +} + export interface AdminEditorAssetPayload { assetId: string; ownerUserId: string; @@ -362,6 +370,8 @@ export interface AdminEditorAssetPayload { taskGenerator: string; taskCostMudPoints: number; children: AdminEditorAssetPayload[]; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export interface AdminEditorAssetListResponse { @@ -413,6 +423,8 @@ export interface AdminEditorShowcaseAssetPayload { rejectedAt?: string | null; updatedAt: string; showcaseCategory?: string | null; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export interface AdminEditorShowcaseListResponse { diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 9e93be572..3b3b41e99 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -1,8 +1,9 @@ -import { X } from 'lucide-react'; +import { Pause, Play, X } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; +import type { AdminEditorImageSequenceFramePayload } from '../api/adminApiTypes'; const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; @@ -18,6 +19,8 @@ export interface AdminPreviewableEditorAsset { objectKey?: string | null; assetKind?: string | null; thumbnailSrc?: string | null; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export function AdminEditorAssetThumbnail({ @@ -98,6 +101,45 @@ function AdminEditorAssetPreviewMedia({ token: string; }) { const mediaKind = resolveAdminAssetMediaKind(entry); + const label = entry.label || entry.assetId; + + if (mediaKind === 'image-sequence') { + const sequence = normalizeAdminImageSequence(entry); + return sequence ? ( + + ) : ( +
+ 序列数据损坏,无法预览 +
+ ); + } + + return ( + + ); +} + +function AdminEditorStandardAssetPreviewMedia({ + entry, + mediaKind, + token, +}: { + entry: AdminPreviewableEditorAsset; + mediaKind: Exclude; + token: string; +}) { const isAudio = mediaKind === 'audio'; const isVideo = mediaKind === 'video'; const mediaSrc = useAdminResolvedAssetUrl( @@ -162,8 +204,156 @@ function AdminEditorAssetPreviewMedia({ ); } +function AdminEditorImageSequenceFrame({ + entry, + frame, + frameKey, + visible, + loaded, + token, + onReady, +}: { + entry: AdminPreviewableEditorAsset; + frame: AdminEditorImageSequenceFramePayload; + frameKey: string; + visible: boolean; + loaded: boolean; + token: string; + onReady: (frameKey: string) => void; +}) { + const resolvedUrl = useAdminResolvedAssetUrl( + token, + frame.imageSrc, + frame.objectKey, + ); + if (!resolvedUrl) { + return null; + } + return ( + {visible onReady(frameKey)} + /> + ); +} + +function AdminEditorImageSequencePreview({ + entry, + frames, + durationMs, + token, +}: { + entry: AdminPreviewableEditorAsset; + frames: AdminEditorImageSequenceFramePayload[]; + durationMs: number; + token: string; +}) { + const frameItems = frames.map((frame, index) => ({ + frame, + index, + key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'), + })); + const sequenceKey = frameItems.map((item) => item.key).join('|'); + const firstFrameKey = frameItems[0]?.key ?? ''; + const [frameIndex, setFrameIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(true); + const [loadedFrameKeys, setLoadedFrameKeys] = useState>( + () => new Set(), + ); + const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const currentFrameItem = + frameItems[Math.min(frameIndex, frameItems.length - 1)]; + const currentFrameKey = currentFrameItem?.key ?? ''; + + const handleFrameReady = useCallback((frameKey: string) => { + setLoadedFrameKeys((currentKeys) => { + if (currentKeys.has(frameKey)) { + return currentKeys; + } + const nextKeys = new Set(currentKeys); + nextKeys.add(frameKey); + return nextKeys; + }); + }, []); + + useEffect(() => { + setFrameIndex(0); + setIsPlaying(true); + setLoadedFrameKeys(new Set()); + setVisibleFrameKey(firstFrameKey); + }, [firstFrameKey, sequenceKey]); + + useEffect(() => { + if (!currentFrameKey) { + setVisibleFrameKey(''); + return; + } + if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { + return; + } + setVisibleFrameKey(currentFrameKey); + }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); + + useEffect(() => { + if (!isPlaying) { + return undefined; + } + const frameIntervalMs = durationMs / frames.length; + const timer = window.setInterval(() => { + setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); + }, frameIntervalMs); + return () => window.clearInterval(timer); + }, [durationMs, frames.length, isPlaying]); + + return ( +
+ {frameItems.map((item) => ( + + ))} +
+ + {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`} +
+
+ ); +} + function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) { const mediaKind = resolveAdminAssetMediaKind(entry); + if (mediaKind === 'image-sequence') { + const sequence = normalizeAdminImageSequence(entry); + const firstFrame = sequence?.frames[0]; + return firstFrame + ? { src: firstFrame.imageSrc, objectKey: firstFrame.objectKey ?? null } + : { src: '', objectKey: null }; + } if (mediaKind === 'audio') { return { src: AUDIO_ASSET_COVER_SRC, objectKey: null }; } @@ -216,19 +406,58 @@ function useAdminAssetThumbnailVisibility() { return { observeElement, shouldLoad }; } -type AdminAssetMediaKind = 'image' | 'audio' | 'video'; +type AdminAssetMediaKind = 'image' | 'audio' | 'video' | 'image-sequence'; + +function normalizeAdminImageSequence(entry: AdminPreviewableEditorAsset) { + const frames = entry.imageSequenceFrames; + const durationMs = entry.imageSequenceDurationMs; + if ( + !Array.isArray(frames) || + frames.length < 2 || + typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs <= 0 + ) { + return null; + } + const normalizedFrames = frames.flatMap((frame) => { + const imageSrc = frame.imageSrc?.trim() ?? ''; + if ( + !imageSrc || + !Number.isFinite(frame.width) || + frame.width <= 0 || + !Number.isFinite(frame.height) || + frame.height <= 0 + ) { + return []; + } + return [ + { + ...frame, + imageSrc, + objectKey: frame.objectKey?.trim() || null, + assetObjectId: frame.assetObjectId?.trim() || null, + }, + ]; + }); + return normalizedFrames.length === frames.length + ? { frames: normalizedFrames, durationMs } + : null; +} function resolveAdminAssetMediaKind( entry: AdminPreviewableEditorAsset, ): AdminAssetMediaKind { + const assetKind = entry.assetKind?.trim() ?? ''; + if (assetKind === 'character-animation') { + return 'image-sequence'; + } const pathMediaKind = resolveAdminAssetMediaKindFromPath(entry.imageSrc) ?? resolveAdminAssetMediaKindFromPath(entry.objectKey ?? ''); if (pathMediaKind) { return pathMediaKind; } - - const assetKind = entry.assetKind?.trim() ?? ''; if ( assetKind === 'sound-effect' || assetKind === 'background-music' || diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 5bd550cd3..f27ccc5e2 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1068,7 +1068,7 @@ test('后台素材查询点击图片缩略图可打开放大预览', async () => }); }); -test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => { +test('后台素材查询在现有预览弹窗播放完整角色动作序列', async () => { const user = userEvent.setup(); vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -1081,17 +1081,41 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () assetKind: 'character-animation', thumbnailSrc: '/generated-animations/editor/source-1/task-1/frame00.png', + imageSequenceFrames: [ + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame00.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame00.png', + width: 192, + height: 256, + }, + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame01.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame01.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 250, }, ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ - read: { - objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', - signedUrl: 'https://signed.example.com/character-animation-frame00.png', - expiresAt: '2026-07-04T11:00:00Z', + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const objectKey = request.objectKey ?? undefined; + return { + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey ?? ''}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }; }, - }); + ); render( , @@ -1102,15 +1126,31 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = await within(dialog).findByRole('img', { - name: '图片预览:角色动作首帧', - }); + expect( + await within(dialog).findByRole('button', { + name: '暂停角色动作', + }), + ).toBeTruthy(); await waitFor(() => { - expect(image.getAttribute('src')).toBe( - 'https://signed.example.com/character-animation-frame00.png', - ); + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame'), + ).toHaveLength(2); }); - expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull(); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame01.png', + expireSeconds: 300, + }); + + await user.click( + within(dialog).getByRole('button', { name: '暂停角色动作' }), + ); + expect( + within(dialog).getByRole('button', { name: '播放角色动作' }), + ).toBeTruthy(); }); test('后台素材查询音频素材使用统一封面缩略图', async () => { diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index f207ba285..238bdeba5 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -318,6 +318,101 @@ test('后台精选审核缩略图进入视口后换签并可打开图片预览', expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull(); }); +test('后台精选审核在共用预览弹窗播放完整角色动作', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + label: '待机动作', + assetKind: 'character-animation', + imageSrc: '/generated/action/frame-01.png', + objectKey: 'generated/action/frame-01.png', + imageSequenceFrames: [ + { + 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, + }, + ], + imageSequenceDurationMs: 250, + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const objectKey = request.objectKey ?? undefined; + return { + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey ?? ''}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + }, + ); + + render( + , + ); + fireEvent.click(await screen.findByTitle('预览素材')); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + expect( + await within(dialog).findByRole('button', { name: '暂停角色动作' }), + ).toBeTruthy(); + await waitFor(() => { + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame'), + ).toHaveLength(2); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated/action/frame-02.png', + expireSeconds: 300, + }); +}); + +test('后台精选审核对损坏角色动作显示错误而不回退首帧', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + label: '损坏动作', + assetKind: 'character-animation', + imageSequenceFrames: null, + imageSequenceDurationMs: null, + }, + ], + nextCursor: null, + }); + + render( + , + ); + fireEvent.click(await screen.findByTitle('预览素材')); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + expect( + within(dialog).getByLabelText('角色动作序列损坏:损坏动作'), + ).toBeTruthy(); + expect( + within(dialog).queryByRole('img', { name: '图片预览:损坏动作' }), + ).toBeNull(); +}); + test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [ diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 191fbd840..d53628c5b 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1703,6 +1703,54 @@ button:disabled { object-fit: contain; } +.admin-asset-query-sequence-preview { + position: relative; + width: min(100%, 720px); + min-height: min(64dvh, 560px); + overflow: hidden; +} + +.admin-asset-query-sequence-frame { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} + +.admin-asset-query-sequence-controls { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + border-radius: 999px; + background: rgba(43, 31, 22, 0.82); + color: #fff; + padding: 6px 10px; + font-size: 12px; +} + +.admin-asset-query-sequence-controls button { + display: grid; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; + place-items: center; +} + +.admin-asset-query-sequence-error { + display: grid; + color: #9d3127; + place-items: center; +} + .admin-asset-query-preview-audio { display: grid; justify-items: center; diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index a85a5d614..3bb0c5d51 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ContextType } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -147,6 +154,7 @@ describe('CreationLandingView', () => { listPublicEditorProjectResourcesMock.mockReset(); createEditorProjectMock.mockReset(); toggleEditorShowcaseAssetLikeMock.mockReset(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -470,6 +478,77 @@ describe('CreationLandingView', () => { expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy(); }); + it('plays a character action on card focus and in the preview dialog', async () => { + const user = userEvent.setup(); + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ + { + resourceId: 'resource-action', + projectId: 'editor-showcase', + showcaseId: 'showcase-action', + label: '待机动作', + imageSrc: '/public/action/frame-01.png', + objectKey: null, + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + generationCostMudPoints: 103, + imageSequenceFrames: [ + { + imageSrc: '/public/action/frame-01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/public/action/frame-02.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 250, + }, + ]); + + renderCreationLanding(); + const title = await screen.findByText('待机动作'); + const card = title.closest('.creation-landing__asset-card') as HTMLElement; + const openButton = card.querySelector( + '.creation-landing__asset-card-open', + ) as HTMLButtonElement; + + expect( + card.querySelectorAll('.creation-landing__image-sequence-frame'), + ).toHaveLength(0); + + fireEvent.focus(openButton); + await waitFor(() => { + expect( + card.querySelectorAll('.creation-landing__image-sequence-frame'), + ).toHaveLength(2); + }); + expect( + setIntervalSpy.mock.calls.some(([, delay]) => delay === 125), + ).toBe(true); + + await user.click(openButton); + expect( + await screen.findByRole('button', { name: '暂停角色动作' }), + ).toBeTruthy(); + expect( + document.querySelectorAll( + '.creation-landing__showcase-current .creation-landing__image-sequence-frame', + ), + ).toHaveLength(2); + + await user.click(screen.getByRole('button', { name: '暂停角色动作' })); + expect( + screen.getByRole('button', { name: '播放角色动作' }), + ).toBeTruthy(); + setIntervalSpy.mockRestore(); + }); + it('likes a featured asset from the list card action', async () => { const user = userEvent.setup(); listEditorProjectsMock.mockResolvedValueOnce(projectItems); @@ -611,6 +690,19 @@ describe('CreationLandingView', () => { taskId: 'task-2', assetKind: 'character-animation', priceMudPoints: 40, + imageSequenceFrames: [ + { + imageSrc: 'data:image/png;base64,animation-1', + width: 512, + height: 512, + }, + { + imageSrc: 'data:image/png;base64,animation-2', + width: 512, + height: 512, + }, + ], + imageSequenceDurationMs: 4_000, generationInputs: { fields: [{ title: '动作描述', value: '待机动作' }], references: [ diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index b3496951f..cca902420 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,4 +1,10 @@ -import { Film, Image as ImageIcon, Music2 } from 'lucide-react'; +import { + Film, + Image as ImageIcon, + Music2, + Pause, + Play, +} from 'lucide-react'; import { useCallback, useEffect, @@ -239,7 +245,10 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) { if (preview.mediaType === 'audio') { return
@@ -679,6 +714,7 @@ export function CreationLandingView({ onOpenCommunity, searchKeyword = '', }: CreationLandingViewProps) { + const prefersReducedMotion = usePrefersReducedMotion(); const authUi = useAuthUi(); const isAuthenticated = Boolean(authUi?.user); const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase(); @@ -706,7 +742,9 @@ export function CreationLandingView({ useState('all'); const [selectedShowcaseItem, setSelectedShowcaseItem] = useState(null); - const [activeShowcaseSequenceCardId, setActiveShowcaseSequenceCardId] = + const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] = + useState(null); + const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] = useState(null); const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0); const [likedShowcaseIds, setLikedShowcaseIds] = useState>( @@ -1095,16 +1133,16 @@ export function CreationLandingView({ type="button" className="creation-landing__asset-card-open" onPointerEnter={() => - setActiveShowcaseSequenceCardId(item.id) + setHoveredShowcaseSequenceCardId(item.id) } onPointerLeave={() => - setActiveShowcaseSequenceCardId((current) => + setHoveredShowcaseSequenceCardId((current) => current === item.id ? null : current, ) } - onFocus={() => setActiveShowcaseSequenceCardId(item.id)} + onFocus={() => setFocusedShowcaseSequenceCardId(item.id)} onBlur={() => - setActiveShowcaseSequenceCardId((current) => + setFocusedShowcaseSequenceCardId((current) => current === item.id ? null : current, ) } @@ -1116,7 +1154,9 @@ export function CreationLandingView({ @@ -1374,6 +1414,7 @@ export function CreationLandingView({ setSelectedShowcaseItem(null)} /> -- 2.52.0 From 7b1adbd5a28ab68f73d8f7b444bc731bacc18672 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 11:41:48 +0800 Subject: [PATCH 15/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=A7=92=E8=89=B2=E5=8A=A8=E4=BD=9C=E8=A1=80=E7=BC=98?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无项目生成返回正式账号素材时沿用素材记录的直接来源资源。 序列帧导出失败提示只按帧源是否存在分类,不把可选时长误报为缺帧。 补充账号素材独立返回时的动作血缘回归测试和角色动作方案说明。 --- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 2 +- .../ImageCanvasGenerationLayerModel.test.ts | 55 +++++++++++++++++++ .../ImageCanvasGenerationLayerModel.ts | 5 +- .../useImageCanvasAssetExportWorkflow.ts | 6 +- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 648bfdf95..d13a2a2bb 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -183,7 +183,7 @@ - BgFilter、阿里云或本地键色返回透明结果后,父流程继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边;最终帧 OSS 与业务写回仍由父流程完成。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 - 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按后端内部 `frame_index` 排序;该索引只服务并发收口、OSS 命名、日志和错误定位,不写入正式 asset / resource 帧对象。BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 -- 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。 +- 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`;此时图层的 `sourceResourceId` 继续使用正式账号素材记录的直接来源,不回退成请求中的原角色资源。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。 - 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。 - 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。 - 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。 diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 790d4a886..8e04fcf78 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -733,4 +733,59 @@ describe('ImageCanvasGenerationLayerModel', () => { imageSequenceDurationMs: 4_000, }); }); + + it('keeps persisted animation lineage when generation returns only an asset', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/frame1.png', + width: 512, + height: 768, + }, + { + imageSrc: '/generated-character-drafts/editor/frame2.png', + width: 512, + height: 768, + }, + ]; + const layer = createCharacterAnimationResultLayer({ + generated: { + taskId: 'character-animation-task-asset-only', + model: 'seedance2.0-fast', + prompt: '跳跃动作', + previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + frames, + frameCount: 2, + durationSeconds: 4, + fps: 8, + priceMudPoints: 40, + asset: { + assetId: 'asset-character-animation-final', + folderId: 'user-1:asset-folder:project', + label: '角色跳跃', + imageSrc: frames[0]!.imageSrc, + width: 512, + height: 768, + sourceType: 'generated', + sourceResourceId: 'resource-character-animation-preview', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + }, + generatedIndex: 17, + title: '角色跳跃', + canvasSize: { width: 900, height: 640 }, + viewport: { x: 0, y: 0, scale: 1 }, + generationInputs: createGenerationInputs(), + sourceLayer: createSourceLayer({ + resourceId: 'resource-original-character', + }), + }); + + expect(layer).toMatchObject({ + resourceId: 'local-resource-asset-character-animation-final', + sourceResourceId: 'resource-character-animation-preview', + sourceAssetId: 'asset-character-animation-final', + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 8d45623a7..6672aebf9 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -593,7 +593,10 @@ export function createCharacterAnimationResultLayer({ objectKey: persisted?.objectKey, assetObjectId: persisted?.assetObjectId, sourceAssetId: asset?.assetId, - sourceResourceId: resource?.sourceResourceId ?? sourceLayer?.resourceId, + sourceResourceId: + resource?.sourceResourceId ?? + asset?.sourceResourceId ?? + sourceLayer?.resourceId, groupId: sourceLayer?.groupId, generationInputs: persisted?.generationInputs ?? generationInputs, generatedAssetSnapshot: asset, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 3441e37d2..7166b6b52 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -302,12 +302,10 @@ export function useImageCanvasAssetExportWorkflow({ } }) .catch(() => { - const hasUsableFrameSource = - (layer.imageSequenceFrames?.length ?? 0) > 0 && - (layer.imageSequenceDurationMs ?? 0) > 0; + const hasFrameSource = (layer.imageSequenceFrames?.length ?? 0) > 0; setAssetExportStatus({ tone: 'error', - message: hasUsableFrameSource + message: hasFrameSource ? '序列帧导出失败' : `角色动作素材“${layer.title}”不完整:缺少可用序列帧。`, }); -- 2.52.0 From 351f41fd12809de8e38634b6cf5ffc0c8ace3b6d 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 11:42:07 +0800 Subject: [PATCH 16/42] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E7=94=BB=E5=B8=83=E6=97=B6=E9=95=BF=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非角色动作图层继续从画布布局恢复音频和视频运行时秒数。 账号素材转图层时保留前端临时媒体时长,不写入角色动作正式字段。 补充音频图层时长恢复回归测试。 --- src/components/image-editor/ImageCanvasEditorModel.test.ts | 2 ++ src/components/image-editor/ImageCanvasEditorModel.ts | 4 ++++ src/components/image-editor/ImageCanvasEditorTypes.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 3e0eb519f..47941dd7e 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -589,6 +589,7 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', mediaType: 'audio', assetKind: 'sound-effect', + durationSeconds: 3.5, }, new Map([ [ @@ -605,6 +606,7 @@ describe('ImageCanvasEditorModel', () => { src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', assetKind: 'sound-effect', + durationSeconds: 3.5, }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 91595b6de..ee3ab0b83 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -186,6 +186,7 @@ export function createLayerFromAsset( objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, sourceResourceId: asset.sourceResourceId, + durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, assetKind, generationInputs: asset.generationInputs, @@ -781,6 +782,9 @@ export function hydrateLayer( ? stringOrNull(resource?.assetObjectId) : stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId), + durationSeconds: isCharacterAnimation + ? undefined + : audioDurationOrNull(snapshot.durationSeconds), imageSequenceDurationMs: isCharacterAnimation ? audioDurationOrNull(resource?.imageSequenceDurationMs) : undefined, diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index ff6795de0..a426d7afe 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -60,6 +60,7 @@ export type EditorAsset = { showcaseSubmitError?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; + durationSeconds?: number; imageSequenceFrames?: EditorImageSequenceFrameResult[]; imageSequenceDurationMs?: number; uploadStatus?: 'uploading' | 'failed'; -- 2.52.0 From da42876c63edecc81bfa402635f1e6bfa4e3e588 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 12:12:59 +0800 Subject: [PATCH 17/42] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E9=A2=84=E8=A7=88=E5=A4=B1=E8=B4=A5=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台和创作主页序列播放器识别换签失败与图片解码失败并跳过故障帧。 全部帧失败时停止播放,弹窗显示失败帧数量并允许重新换签加载。 卡片全帧失败时引导打开预览重试,避免嵌套交互按钮。 补充故障帧跳过、手动重试和预读窗口恢复回归测试与项目记忆。 --- .../src/components/AdminEditorAssetMedia.tsx | 176 +++++++++++++++-- .../pages/AdminEditorAssetQueryPage.test.tsx | 22 ++- docs/project-memory/shared-memory/pitfalls.md | 2 +- .../CreationLandingView.test.tsx | 12 ++ .../creation-home/CreationLandingView.tsx | 179 ++++++++++++++---- src/index.css | 14 ++ 6 files changed, 352 insertions(+), 53 deletions(-) diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 73a837610..6fb205851 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -1,5 +1,5 @@ import { Pause, Play, X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; @@ -34,7 +34,7 @@ export function AdminEditorAssetThumbnail({ }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); - const imageSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, @@ -142,12 +142,12 @@ function AdminEditorStandardAssetPreviewMedia({ }) { const isAudio = mediaKind === 'audio'; const isVideo = mediaKind === 'video'; - const mediaSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl( token, entry.imageSrc, entry.objectKey, ); - const posterSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl( token, isVideo ? (entry.thumbnailSrc ?? '') : '', null, @@ -211,7 +211,9 @@ function AdminEditorImageSequenceFrame({ visible, loaded, token, + retryGeneration, onReady, + onFailed, }: { entry: AdminPreviewableEditorAsset; frame: AdminEditorImageSequenceFramePayload; @@ -219,13 +221,32 @@ function AdminEditorImageSequenceFrame({ visible: boolean; loaded: boolean; token: string; + retryGeneration: number; onReady: (frameKey: string) => void; + onFailed: (frameKey: string) => void; }) { - const resolvedUrl = useAdminResolvedAssetUrl( + const failureReportedRef = useRef(false); + const { resolvedUrl, hasError } = useAdminResolvedAssetUrl( token, frame.imageSrc, frame.objectKey, + true, + `${frameKey}:${retryGeneration}`, ); + const reportFailure = useCallback(() => { + if (failureReportedRef.current) { + return; + } + failureReportedRef.current = true; + onFailed(frameKey); + }, [frameKey, onFailed]); + + useEffect(() => { + if (hasError) { + reportFailure(); + } + }, [hasError, reportFailure]); + if (!resolvedUrl) { return null; } @@ -238,6 +259,7 @@ function AdminEditorImageSequenceFrame({ decoding="async" style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }} onLoad={() => onReady(frameKey)} + onError={reportFailure} /> ); } @@ -253,11 +275,17 @@ function AdminEditorImageSequencePreview({ durationMs: number; token: string; }) { - const frameItems = frames.map((frame, index) => ({ - frame, - index, - key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'), - })); + const frameItems = useMemo( + () => + frames.map((frame, index) => ({ + frame, + index, + key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join( + ':', + ), + })), + [entry.assetId, frames], + ); const sequenceKey = frameItems.map((item) => item.key).join('|'); const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); @@ -265,17 +293,29 @@ function AdminEditorImageSequencePreview({ const [loadedFrameKeys, setLoadedFrameKeys] = useState>( () => new Set(), ); + const [failedFrameKeys, setFailedFrameKeys] = useState>( + () => new Set(), + ); + const [retryGeneration, setRetryGeneration] = useState(0); const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); const currentFrameItem = frameItems[Math.min(frameIndex, frameItems.length - 1)]; const currentFrameKey = currentFrameItem?.key ?? ''; - const mountedFrameKeys = new Set([visibleFrameKey]); - for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) { + const mountedFrameKeys = new Set(); + if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) { + mountedFrameKeys.add(visibleFrameKey); + } + for ( + let offset = 0; + offset < frameItems.length && mountedFrameKeys.size < 3; + offset += 1 + ) { const item = frameItems[(frameIndex + offset) % frameItems.length]; - if (item) { + if (item && !failedFrameKeys.has(item.key)) { mountedFrameKeys.add(item.key); } } + const availableFrameCount = frameItems.length - failedFrameKeys.size; const handleFrameReady = useCallback((frameKey: string) => { setLoadedFrameKeys((currentKeys) => { @@ -288,13 +328,51 @@ function AdminEditorImageSequencePreview({ }); }, []); + const handleFrameFailed = useCallback((frameKey: string) => { + setFailedFrameKeys((currentKeys) => { + if (currentKeys.has(frameKey)) { + return currentKeys; + } + const nextKeys = new Set(currentKeys); + nextKeys.add(frameKey); + return nextKeys; + }); + }, []); + + const retryFailedFrames = useCallback(() => { + setFrameIndex(0); + setIsPlaying(true); + setLoadedFrameKeys(new Set()); + setFailedFrameKeys(new Set()); + setVisibleFrameKey(firstFrameKey); + setRetryGeneration((current) => current + 1); + }, [firstFrameKey]); + useEffect(() => { setFrameIndex(0); setIsPlaying(true); setLoadedFrameKeys(new Set()); + setFailedFrameKeys(new Set()); setVisibleFrameKey(firstFrameKey); }, [firstFrameKey, sequenceKey]); + useEffect(() => { + if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) { + return; + } + const nextIndex = findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + frameIndex, + ); + if (nextIndex === null) { + setVisibleFrameKey(''); + setIsPlaying(false); + return; + } + setFrameIndex(nextIndex); + }, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]); + useEffect(() => { if (!currentFrameKey) { setVisibleFrameKey(''); @@ -307,15 +385,29 @@ function AdminEditorImageSequencePreview({ }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { - if (!isPlaying) { + if (!isPlaying || availableFrameCount < 2) { return undefined; } const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length); const timer = window.setInterval(() => { - setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); + setFrameIndex( + (currentIndex) => + findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + currentIndex, + ) ?? currentIndex, + ); }, frameIntervalMs); return () => window.clearInterval(timer); - }, [durationMs, frames.length, isPlaying]); + }, [ + availableFrameCount, + durationMs, + failedFrameKeys, + frameItems, + frames.length, + isPlaying, + ]); return (
mountedFrameKeys.has(item.key)) .map((item) => ( ))}
@@ -349,11 +443,34 @@ function AdminEditorImageSequencePreview({ )} {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`} + {failedFrameKeys.size ? ( + <> + {`${failedFrameKeys.size} 帧加载失败`} + + + ) : null}
); } +function findNextAvailableFrameIndex( + frameItems: ReadonlyArray<{ key: string }>, + failedFrameKeys: ReadonlySet, + currentIndex: number, +) { + for (let offset = 1; offset <= frameItems.length; offset += 1) { + const nextIndex = (currentIndex + offset) % frameItems.length; + const item = frameItems[nextIndex]; + if (item && !failedFrameKeys.has(item.key)) { + return nextIndex; + } + } + return null; +} + function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) { const mediaKind = resolveAdminAssetMediaKind(entry); if (mediaKind === 'image-sequence') { @@ -517,6 +634,7 @@ function useAdminResolvedAssetUrl( imageSrc: string | null | undefined, objectKey: string | null | undefined, enabled = true, + refreshKey: string | number | null = null, ) { const normalizedImageSrc = imageSrc?.trim() ?? ''; const normalizedObjectKey = normalizeAdminObjectKey(objectKey); @@ -528,18 +646,26 @@ function useAdminResolvedAssetUrl( const [resolvedImageSrc, setResolvedImageSrc] = useState( shouldResolve ? '' : normalizedImageSrc, ); + const [isResolving, setIsResolving] = useState(enabled && shouldResolve); + const [hasError, setHasError] = useState(false); useEffect(() => { if (!normalizedImageSrc && !normalizedObjectKey) { setResolvedImageSrc(''); + setIsResolving(false); + setHasError(false); return; } if (!shouldResolve) { setResolvedImageSrc(normalizedImageSrc); + setIsResolving(false); + setHasError(false); return; } if (!enabled) { setResolvedImageSrc(''); + setIsResolving(false); + setHasError(false); return; } @@ -548,6 +674,8 @@ function useAdminResolvedAssetUrl( let retryIndex = 0; const dispatchController = new AbortController(); setResolvedImageSrc(''); + setIsResolving(true); + setHasError(false); const resolveReadUrl = async () => { try { @@ -568,7 +696,10 @@ function useAdminResolvedAssetUrl( }, ); if (!cancelled) { - setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response)); + const nextUrl = resolveAdminAssetReadSignedUrl(response); + setResolvedImageSrc(nextUrl); + setIsResolving(false); + setHasError(!nextUrl); } } catch (error: unknown) { if (cancelled) { @@ -585,6 +716,8 @@ function useAdminResolvedAssetUrl( return; } setResolvedImageSrc(''); + setIsResolving(false); + setHasError(true); } }; @@ -602,11 +735,16 @@ function useAdminResolvedAssetUrl( normalizedImageSrc, normalizedLegacyPublicPath, normalizedObjectKey, + refreshKey, shouldResolve, token, ]); - return resolvedImageSrc; + return { + resolvedUrl: resolvedImageSrc, + isResolving, + hasError, + }; } async function waitForAdminAssetReadDispatch(signal: AbortSignal) { diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index cd0b63846..7e83b37bb 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1177,8 +1177,28 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as expireSeconds: 300, }); + const failedFrame = dialog.querySelector( + '.admin-asset-query-sequence-frame', + ) as HTMLImageElement; + fireEvent.error(failedFrame); + expect(await within(dialog).findByText('1 帧加载失败')).toBeTruthy(); + await waitFor(() => { + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame03.png', + expireSeconds: 300, + }); + }); + const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length; + await user.click( + within(dialog).getByRole('button', { name: '重试失败帧' }), + ); + await waitFor(() => { + expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan( + callsBeforeRetry, + ); + }); expect( - within(dialog).getByRole('button', { name: '播放角色动作' }), + within(dialog).getByRole('button', { name: '暂停角色动作' }), ).toBeTruthy(); }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c4e5603fa..809f9345d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3913,7 +3913,7 @@ - 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。 - 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。 -- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。 +- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;单帧换签或解码失败时跳过该帧、暂停全帧失败的序列并提供显式重试,不能长期显示空白或旧帧。卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。 - 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。 ## 可复用资源回填必须保持时间戳单调 diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 5c747035b..0f2f5b7dc 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -569,6 +569,18 @@ describe('CreationLandingView', () => { ), ).toHaveLength(3); + const failedModalFrame = document.querySelector( + '.creation-landing__showcase-current .creation-landing__image-sequence-frame', + ) as HTMLImageElement; + await user.click(screen.getByRole('button', { name: '暂停角色动作' })); + fireEvent.error(failedModalFrame); + expect(await screen.findByText('1 帧加载失败')).toBeTruthy(); + expect(await screen.findByText('2/5')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '重试失败帧' })); + await waitFor(() => { + expect(screen.queryByText('1 帧加载失败')).toBeNull(); + }); + await user.click(screen.getByRole('button', { name: '暂停角色动作' })); expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy(); setIntervalSpy.mockRestore(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 876af7ca7..43b6f1583 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,10 +1,4 @@ -import { - Film, - Image as ImageIcon, - Music2, - Pause, - Play, -} from 'lucide-react'; +import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react'; import { useCallback, useEffect, @@ -269,10 +263,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) { if (preview.mediaType === 'audio') { return