修复角色动作素材入库

将最终角色动作帧序列保存为单条素材并保留完整元数据
登记全部动作帧的私有资产对象并以首帧作为预览
补充动作素材持久化测试和后端数据契约
This commit is contained in:
2026-07-14 15:48:30 +08:00
parent 2653adeb34
commit aa56c117ec
2 changed files with 237 additions and 13 deletions
@@ -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`
@@ -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<Vec<EditorCharacterAnimationFramePayload>, AppError> {
) -> Result<PersistedEditorCharacterAnimationFrameSet, AppError> {
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<module_assets::ConfirmAssetObjectResult, AppError> {
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<module_assets::ConfirmAssetObjectResult, AppError> {
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<module_assets::ConfirmAssetObjectResult, AppError> {
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<Value>,
preview_video_path: &str,
frames: &[EditorCharacterAnimationFramePayload],
fps: u32,
duration_seconds: u32,
) -> Option<Value> {
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<EditorCharacterAnimationFramePayload>,
first_frame_object_key: String,
first_frame_asset_object_id: String,
}
// 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。
struct CharacterAnimationGeneratedDraft {
image_sources: Vec<String>,
@@ -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");