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 7e02470c1..40e21bc23 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -741,104 +741,109 @@ pub(crate) async fn generate_editor_character_animation_for_owner( source_data_url.as_str(), ) .await?; - let source_record = persist_editor_generated_media_asset( - &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id - .clone() - .or_else(|| Some("project".to_string())), - label: editor_character_animation_source_asset_label(asset_label.as_str()), - image_src: generated.preview_video_path.clone(), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - width: normalized.frame_width, - height: normalized.frame_height, - prompt: normalized.prompt.clone(), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), - provider: "Ark".to_string(), - task_id: task_id.clone(), - source_resource_id: source_resource_id.clone(), - asset_kind: Some( - EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), - ), - generation_inputs: generation_inputs.clone(), - thumbnail_src: None, - generation_cost_mud_points: u64::from(normalized.price_mud_points), - }, - ) - .await?; - if let Some(reporter) = phase_reporter.as_ref() { - reporter.report_processing(&state).await?; - } - let provider_source_resource_id = source_record - .0 - .as_ref() - .map(|resource| resource.resource_id.clone()); - let persisted_frames = extract_and_persist_editor_character_animation_frames( - &state, - owner_user_id.as_str(), - normalized.source_layer_id.as_str(), - task_id.as_str(), - generated.preview_video_path.as_str(), - &normalized, - &extraction_settings, - &matting_audit, - ) - .await?; - let frames = persisted_frames.frames; - let generation_inputs = attach_editor_character_animation_result_metadata( - generation_inputs, - generated.preview_video_path.as_str(), - frames.as_slice(), - normalized.fps, - normalized.duration_seconds, - ); - let first_frame = frames.first().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-character-animation", - "message": "角色动作抽帧完成但没有可保存的序列帧。", - })) - })?; - persist_editor_generated_media_asset( - &state, - PersistEditorGeneratedAssetRequest { - project_id: None, - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id - .clone() - .or_else(|| Some("project".to_string())), - label: asset_label.clone(), - image_src: first_frame.image_src.clone(), - object_key: Some(persisted_frames.first_frame_object_key), - asset_object_id: Some(persisted_frames.first_frame_asset_object_id), - width: first_frame.width, - height: first_frame.height, - prompt: normalized.prompt.clone(), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), - provider: "Ark".to_string(), - task_id: task_id.clone(), - source_resource_id: provider_source_resource_id, - 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, - }, - ) - .await?; - - Ok::<_, AppError>((generated, frames, normalized, generation_inputs)) + // 扣费确认边界 = provider 成功:预览视频已生成即在此收口计费闭包, + // 后续持久化 / phase 上报 / 抽帧移到闭包外,失败不再触发退款(与角色生图、编辑器视频一致)。 + Ok::<_, AppError>((generated, normalized, generation_inputs)) }, ) .await; - let (generated, frames, normalized, generation_inputs) = + let (generated, normalized, generation_inputs) = result.map_err(|error| character_animation_error_response(&request_context, error))?; + // provider 成功后的失败保留已确认的扣费;承载生成成本的预览视频素材先落库,供用户在后续步骤失败时找回。 + let post_provider = async { + let source_record = persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + label: editor_character_animation_source_asset_label(asset_label.as_str()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + asset_object_id: generated.asset_object_id.clone(), + width: normalized.frame_width, + height: normalized.frame_height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + provider: "Ark".to_string(), + task_id: task_id.clone(), + source_resource_id: source_resource_id.clone(), + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: u64::from(normalized.price_mud_points), + }, + ) + .await?; + if let Some(reporter) = phase_reporter.as_ref() { + reporter.report_processing(&state).await?; + } + let provider_source_resource_id = source_record + .0 + .as_ref() + .map(|resource| resource.resource_id.clone()); + let persisted_frames = extract_and_persist_editor_character_animation_frames( + &state, + owner_user_id.as_str(), + normalized.source_layer_id.as_str(), + task_id.as_str(), + generated.preview_video_path.as_str(), + &normalized, + &extraction_settings, + &matting_audit, + ) + .await?; + let frames = persisted_frames.frames; + let generation_inputs = attach_editor_character_animation_result_metadata( + generation_inputs, + generated.preview_video_path.as_str(), + frames.as_slice(), + normalized.fps, + normalized.duration_seconds, + ); + let first_frame = frames.first().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有可保存的序列帧。", + })) + })?; + persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: None, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + label: asset_label.clone(), + image_src: first_frame.image_src.clone(), + object_key: Some(persisted_frames.first_frame_object_key), + asset_object_id: Some(persisted_frames.first_frame_asset_object_id), + width: first_frame.width, + height: first_frame.height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + provider: "Ark".to_string(), + task_id: task_id.clone(), + source_resource_id: provider_source_resource_id, + 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, + }, + ) + .await?; + + Ok::<_, AppError>((frames, generation_inputs)) + }; + let (frames, generation_inputs) = post_provider + .await + .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| { character_animation_error_response( @@ -6071,12 +6076,17 @@ mod tests { #[test] fn editor_character_animation_reports_processing_after_video_generation() { let source = include_str!("character_animation_assets.rs"); + // 扣费确认边界 = provider 成功:计费闭包在预览生成返回处收口, + // 预览落库、phase 上报和抽帧都在闭包外,失败不得再触发退款。 assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_character_animation_for_owner", "pub async fn generate_editor_video", &[ + "execute_billable_asset_operation_with_cost", "request_editor_character_animation_preview", + "Ok::<_, AppError>((generated, normalized, generation_inputs))", + "persist_editor_generated_media_asset", "reporter.report_processing(&state).await?", "extract_and_persist_editor_character_animation_frames", ],