diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 594149724..bf01b3157 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -543,7 +543,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_showcase_asset` 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 391e33d95..f4c28f170 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -64,16 +64,16 @@ use crate::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, remove_editor_generated_green_screen_background, }, - editor_screen_background_decision::{ - EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, - resolve_editor_screen_background_color, - }, editor_project::{ EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, }, + editor_screen_background_decision::{ + EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, + resolve_editor_screen_background_color, + }, http_error::AppError, openai_image_generation::DownloadedOpenAiImage, platform_errors::map_oss_error, @@ -763,7 +763,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( }, ) .await?; - let frames = extract_and_persist_editor_character_animation_frames( + let persisted_frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), normalized.source_layer_id.as_str(), @@ -774,6 +774,47 @@ pub(crate) async fn generate_editor_character_animation_for_owner( &matting_audit, ) .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( + &state, + PersistEditorGeneratedAssetRequest { + project_id: None, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + label: asset_label.clone(), + image_src: first_frame.image_src.clone(), + object_key: Some(persisted_frames.first_frame_object_key), + asset_object_id: Some(persisted_frames.first_frame_asset_object_id), + width: first_frame.width, + height: first_frame.height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + provider: "Ark".to_string(), + task_id: task_id.clone(), + source_resource_id: None, + asset_kind: Some("character-animation".to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: Some(first_frame.image_src.clone()), + generation_cost_mud_points: u64::from(normalized.price_mud_points), + }, + ) + .await?; Ok::<_, AppError>((generated, frames, normalized, generation_inputs)) }, @@ -2214,7 +2255,7 @@ async fn extract_and_persist_editor_character_animation_frames( request: &NormalizedEditorCharacterAnimationRequest, extraction_settings: &BackendFrameExtractionSettings, audit: &crate::external_api_audit::ExternalApiAuditContext, -) -> Result, AppError> { +) -> Result { let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, apply_chroma_key: false, @@ -2250,7 +2291,9 @@ async fn extract_and_persist_editor_character_animation_frames( .await?; let mut frame_payloads = Vec::with_capacity(finalized_frames.len()); + let mut first_frame_object = None; for (index, frame) in finalized_frames.into_iter().enumerate() { + let content_type = frame.mime_type.clone(); let put_result = put_character_animation_object( state, LegacyAssetPrefix::Animations, @@ -2272,6 +2315,21 @@ async fn extract_and_persist_editor_character_animation_frames( ), ) .await?; + let confirmed = confirm_editor_character_animation_frame_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + put_result.object_key.clone(), + content_type, + ) + .await?; + if index == 0 { + first_frame_object = Some(( + put_result.object_key.clone(), + confirmed.record.asset_object_id, + )); + } frame_payloads.push(EditorCharacterAnimationFramePayload { frame_index: index as u32 + 1, image_src: put_result.legacy_public_path, @@ -2280,7 +2338,18 @@ async fn extract_and_persist_editor_character_animation_frames( }); } - Ok(frame_payloads) + let (first_frame_object_key, first_frame_asset_object_id) = + first_frame_object.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有生成首帧对象。", + })) + })?; + Ok(PersistedEditorCharacterAnimationFrameSet { + frames: frame_payloads, + first_frame_object_key, + first_frame_asset_object_id, + }) } async fn persist_editor_character_animation_green_screen_source_frames( @@ -2742,6 +2811,47 @@ async fn confirm_editor_character_animation_source_asset_object( task_id: &str, object_key: String, content_type: String, +) -> Result { + confirm_editor_character_animation_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + object_key, + content_type, + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + ) + .await +} + +async fn confirm_editor_character_animation_frame_asset_object( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + object_key: String, + content_type: String, +) -> Result { + confirm_editor_character_animation_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + object_key, + content_type, + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + ) + .await +} + +async fn confirm_editor_character_animation_asset_object( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + object_key: String, + content_type: String, + asset_kind: &str, ) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client @@ -2760,7 +2870,7 @@ async fn confirm_editor_character_animation_source_asset_object( head.content_type.or(Some(content_type)), head.content_length, head.etag, - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string(), + asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), None, @@ -4704,6 +4814,31 @@ 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() @@ -5484,6 +5619,12 @@ struct FinalizedAnimationFrame { extension: String, } +struct PersistedEditorCharacterAnimationFrameSet { + frames: Vec, + first_frame_object_key: String, + first_frame_asset_object_id: String, +} + // 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。 struct CharacterAnimationGeneratedDraft { image_sources: Vec, @@ -5547,9 +5688,11 @@ mod tests { image.put_pixel(4, 4, Rgba([10, 20, 30, 255])); let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0]; - let composited = - composite_source_image_onto_screen_color(&encode_rgba_png_data_url(&image), screen_color) - .expect("透明源图应被合成"); + let composited = composite_source_image_onto_screen_color( + &encode_rgba_png_data_url(&image), + screen_color, + ) + .expect("透明源图应被合成"); let payload = parse_media_data_url(&composited).expect("合成结果应是图片 data URL"); let output = image::load_from_memory(payload.bytes.as_slice()) .expect("合成结果应可解码") @@ -5560,7 +5703,11 @@ mod tests { [screen_color.red, screen_color.green, screen_color.blue, 255], "透明像素应填成背景色" ); - assert_eq!(output.get_pixel(4, 4).0, [10, 20, 30, 255], "不透明像素应保持原色"); + assert_eq!( + output.get_pixel(4, 4).0, + [10, 20, 30, 255], + "不透明像素应保持原色" + ); } #[test] @@ -5932,6 +6079,83 @@ mod tests { ); } + #[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"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "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", + "label: asset_label.clone()", + "asset_kind: Some(\"character-animation\".to_string())", + "generation_cost_mud_points: u64::from(normalized.price_mud_points)", + ], + ); + assert_function_contains( + source, + "async fn extract_and_persist_editor_character_animation_frames", + "async fn persist_editor_character_animation_green_screen_source_frames", + &[ + "confirm_editor_character_animation_frame_asset_object", + "first_frame_object_key", + "first_frame_asset_object_id", + ], + ); + assert_function_contains( + source, + "async fn confirm_editor_character_animation_frame_asset_object", + "async fn bind_character_animation_asset", + &["EDITOR_CHARACTER_ANIMATION_ASSET_KIND"], + ); + } + #[test] fn editor_video_intermediate_outputs_are_registered_before_derivatives() { let source = include_str!("character_animation_assets.rs");