修复角色动作素材入库
将最终角色动作帧序列保存为单条素材并保留完整元数据 登记全部动作帧的私有资产对象并以首帧作为预览 补充动作素材持久化测试和后端数据契约
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user