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(