收紧角色动作后端正式字段契约
角色动作生成只持久化并返回正式 project resource 与 account asset。 删除 API、后台 mapper 对 generationInputs 和布局动作结果的运行时回退。 动作 generationInputs 旧运行字段、screenColorHex 与正式帧 frameIndex 明确拒绝。 动作画布完成项只写资源引用与 placement,普通 layer 响应移除顶层 mediaType。
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<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()
|
||||
@@ -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)",
|
||||
],
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
image_sequence_frames: Option<Value>,
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -123,6 +125,8 @@ pub struct ExternalEditorProjectResourceCreateRequest {
|
||||
source_resource_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
image_sequence_frames: Option<Value>,
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user