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 deb266331..75137203f 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -648,43 +648,19 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) .await .map_err(|error| character_animation_error_response(&request_context, error))?; - // 背景色决策:显式传入 → 视觉 LLM 按源图主体配色自动决策 → 默认色。 - let screen_background_decision = resolve_editor_screen_background_color( - state.llm_client(), - state.creative_agent_gpt5_client(), - EditorScreenBackgroundDecisionInput { - kind: EditorScreenBackgroundDecisionKind::CharacterAnimation, - screen_color: payload.screen_color.clone(), - prompt: payload.prompt_text.clone(), - icon_descriptions: Vec::new(), - reference_count: 1, - source_image_data_url: Some(source_data_url.clone()), - }, - ) - .await - .map_err(|error| character_animation_error_response(&request_context, error))?; - // 把实际选定的背景色落进资产记录,供后续以该资产为参考图时反查旧背景色。 - 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, + // 定价 / 上游设置先于扣费,且都不依赖背景色:用默认色占位归一化(与队列路径一致), + // 把背景色决策挪到预扣泥点之后,避免余额不足 / 生成注定失败时仍白发一次 gpt-5-mini 决策。 + let priced = normalize_editor_character_animation_request_with_pricing( + payload.clone(), &pricing, - screen_background_decision.color, + crate::editor_green_screen::default_editor_screen_background_color(), ) .map_err(|error| character_animation_error_response(&request_context, error))?; - let settings = require_editor_character_animation_settings(&state, &normalized) + let settings = require_editor_character_animation_settings(&state, &priced) .map_err(|error| character_animation_error_response(&request_context, error))?; - // 透明源图先合成到选定背景色:让视频背景确定性地等于抠图键色,而不是赌模型服从提示词。 - let source_data_url = composite_source_image_onto_screen_color( - source_data_url.as_str(), - screen_background_decision.color, - ) - .unwrap_or(source_data_url); - let extraction_settings = resolve_backend_frame_extraction_settings(&state); let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) .map_err(|error| character_animation_error_response(&request_context, error))?; + let extraction_settings = resolve_backend_frame_extraction_settings(&state); let task_id = generate_ai_task_id(current_utc_micros()); // 抠图供应商失败审计上下文:逐帧阿里云抠图失败即使兜底成功,也要进 OTLP + tracking_event。 let matting_audit = crate::external_api_audit::ExternalApiAuditContext { @@ -698,8 +674,40 @@ pub(crate) async fn generate_editor_character_animation_for_owner( owner_user_id.as_str(), EDITOR_CHARACTER_ANIMATION_ASSET_KIND, task_id.as_str(), - u64::from(normalized.price_mud_points), + u64::from(priced.price_mud_points), async { + // 背景色决策:预扣泥点之后才发起(显式传入 → 视觉 LLM 按源图主体配色自动决策 → 默认色)。 + // 决策失败会让本 future 返回 Err,走 execute_billable 的失败退款,用户不损失泥点。 + let screen_background_decision = resolve_editor_screen_background_color( + state.llm_client(), + state.creative_agent_gpt5_client(), + EditorScreenBackgroundDecisionInput { + kind: EditorScreenBackgroundDecisionKind::CharacterAnimation, + screen_color: payload.screen_color.clone(), + prompt: payload.prompt_text.clone(), + icon_descriptions: Vec::new(), + reference_count: 1, + source_image_data_url: Some(source_data_url.clone()), + }, + ) + .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, + &pricing, + screen_background_decision.color, + )?; + // 透明源图先合成到选定背景色:让视频背景确定性地等于抠图键色,而不是赌模型服从提示词。 + let source_data_url = composite_source_image_onto_screen_color( + source_data_url.as_str(), + screen_background_decision.color, + ) + .unwrap_or(source_data_url); let generated = request_editor_character_animation_preview( &state, &http_client, @@ -723,12 +731,12 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) .await?; - Ok::<_, AppError>((generated, frames)) + Ok::<_, AppError>((generated, frames, normalized, generation_inputs)) }, ) .await; - let (generated, frames) = + let (generated, frames, normalized, generation_inputs) = 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| { diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ab683f709..7d0423aeb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1357,28 +1357,10 @@ pub(crate) async fn generate_editor_image_for_owner( let normalized_kind = payload.kind.as_deref().map(str::trim); let is_character_generation = matches!(normalized_kind, Some("character")); - let screen_background_decision = if is_character_generation { - Some( - resolve_editor_screen_background_color( - state.llm_client(), - state.creative_agent_gpt5_client(), - EditorScreenBackgroundDecisionInput { - kind: EditorScreenBackgroundDecisionKind::Character, - screen_color: payload.screen_color.clone(), - prompt: role_setting.clone(), - icon_descriptions: Vec::new(), - reference_count: payload.reference_image_srcs.as_ref().map_or(0, Vec::len), - source_image_data_url: None, - }, - ) - .await?, - ) - } else { - None - }; - let screen_color = screen_background_decision - .as_ref() - .map(|decision| decision.color); + // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 + // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。 + let requested_screen_color = payload.screen_color.clone(); + let reference_count = payload.reference_image_srcs.as_ref().map_or(0, Vec::len); let seg_model = if is_character_generation { Some(parse_editor_bgfilter_seg_model( payload.seg_model.as_deref(), @@ -1405,24 +1387,6 @@ pub(crate) async fn generate_editor_image_for_owner( has_dimension_options, &generation_options, ); - let submitted_prompt = if is_character_generation { - build_editor_character_image_prompt( - role_setting.as_str(), - screen_color.expect("character generation should have screen color"), - ) - } else if is_ui_design_generation { - build_editor_ui_design_prompt( - role_setting.as_str(), - payload - .reference_image_srcs - .as_ref() - .is_some_and(|references| { - references.iter().any(|source| !source.trim().is_empty()) - }), - ) - } else { - role_setting.clone() - }; let failure_context = match normalized_kind { Some("character") => "图片画布生成角色形象", Some("spec") => "图片画布生成规范", @@ -1444,6 +1408,9 @@ pub(crate) async fn generate_editor_image_for_owner( .filter(|source| !source.is_empty()) .take(reference_limit) .collect::>(); + // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, + // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 + let ui_has_references = !reference_sources.is_empty(); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, caller.audit_subject_user_id.clone(), @@ -1485,7 +1452,41 @@ pub(crate) async fn generate_editor_image_for_owner( Some(generation_options.image_size), ); let generate_operation = async { - if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + // 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款, + // 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。 + let screen_background_decision = if is_character_generation { + Some( + resolve_editor_screen_background_color( + state.llm_client(), + state.creative_agent_gpt5_client(), + EditorScreenBackgroundDecisionInput { + kind: EditorScreenBackgroundDecisionKind::Character, + screen_color: requested_screen_color.clone(), + prompt: role_setting.clone(), + icon_descriptions: Vec::new(), + reference_count, + source_image_data_url: None, + }, + ) + .await?, + ) + } else { + None + }; + let screen_color = screen_background_decision + .as_ref() + .map(|decision| decision.color); + let submitted_prompt = if is_character_generation { + build_editor_character_image_prompt( + role_setting.as_str(), + screen_color.expect("character generation should have screen color"), + ) + } else if is_ui_design_generation { + build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) + } else { + role_setting.clone() + }; + let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, &settings, @@ -1497,7 +1498,7 @@ pub(crate) async fn generate_editor_image_for_owner( reference_images.as_slice(), failure_context, ) - .await + .await? } else if reference_images.is_empty() { create_openai_image_generation_with_model( &http_client, @@ -1510,7 +1511,7 @@ pub(crate) async fn generate_editor_image_for_owner( &[], failure_context, ) - .await + .await? } else { create_openai_image_edit_with_references_and_model( &http_client, @@ -1523,18 +1524,23 @@ pub(crate) async fn generate_editor_image_for_owner( reference_images.as_slice(), failure_context, ) - .await - } + .await? + }; + Ok::<_, AppError>((generated, submitted_prompt, screen_background_decision)) }; - let generated = execute_billable_asset_operation_with_cost( - state, - caller.owner_user_id.as_str(), - editor_image_generation_billing_asset_kind(normalized_kind), - request_context.request_id(), - u64::from(configured_price_mud_points), - generate_operation, - ) - .await?; + let (generated, submitted_prompt, screen_background_decision) = + execute_billable_asset_operation_with_cost( + state, + caller.owner_user_id.as_str(), + editor_image_generation_billing_asset_kind(normalized_kind), + request_context.request_id(), + u64::from(configured_price_mud_points), + generate_operation, + ) + .await?; + let screen_color = screen_background_decision + .as_ref() + .map(|decision| decision.color); let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -2896,20 +2902,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; - let screen_background_decision = resolve_editor_screen_background_color( - state.llm_client(), - state.creative_agent_gpt5_client(), - EditorScreenBackgroundDecisionInput { - kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, - screen_color: payload.screen_color.clone(), - prompt: icon_descriptions.join("\n"), - icon_descriptions: icon_descriptions.clone(), - reference_count: 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len), - source_image_data_url: None, - }, - ) - .await?; - let screen_color = screen_background_decision.color; + // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 + // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 + let requested_screen_color = payload.screen_color.clone(); + let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len); let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); @@ -2954,7 +2950,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(generation_options.image_size), )?; let size = generation_options.size.as_str(); - let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, @@ -2965,43 +2960,62 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; - let generated = execute_billable_asset_operation_with_cost( - state, - caller.owner_user_id.as_str(), - "editor_icon_spritesheet", - request_context.request_id(), - u64::from(expected_price_mud_points), - async { - if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - create_openai_nanobanana_generate_content( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - generation_options.aspect_ratio, - generation_options.provider_image_size, - reference_images.as_slice(), - "图片画布生成图标素材 spritesheet", + let (generated, prompt, screen_background_decision) = + execute_billable_asset_operation_with_cost( + state, + caller.owner_user_id.as_str(), + "editor_icon_spritesheet", + request_context.request_id(), + u64::from(expected_price_mud_points), + async { + // 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。 + let screen_background_decision = resolve_editor_screen_background_color( + state.llm_client(), + state.creative_agent_gpt5_client(), + EditorScreenBackgroundDecisionInput { + kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, + screen_color: requested_screen_color.clone(), + prompt: icon_descriptions.join("\n"), + icon_descriptions: icon_descriptions.clone(), + reference_count, + source_image_data_url: None, + }, ) - .await - } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - size, - 1, - reference_images.as_slice(), - "图片画布生成图标素材 spritesheet", - ) - .await - } - }, - ) - .await?; + .await?; + let screen_color = screen_background_decision.color; + let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color); + let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + generation_options.aspect_ratio, + generation_options.provider_image_size, + reference_images.as_slice(), + "图片画布生成图标素材 spritesheet", + ) + .await? + } else { + create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + size, + 1, + reference_images.as_slice(), + "图片画布生成图标素材 spritesheet", + ) + .await? + }; + Ok::<_, AppError>((generated, prompt, screen_background_decision)) + }, + ) + .await?; + let screen_color = screen_background_decision.color; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -3174,23 +3188,14 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( caller: EditorGenerationCaller, payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { - let screen_background_decision = resolve_editor_screen_background_color( - state.llm_client(), - state.creative_agent_gpt5_client(), - EditorScreenBackgroundDecisionInput { - kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction, - screen_color: payload.screen_color.clone(), - prompt: format!( - "从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。", - payload.aspect_ratio, payload.image_size - ), - icon_descriptions: Vec::new(), - reference_count: 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len), - source_image_data_url: None, - }, - ) - .await?; - let screen_color = screen_background_decision.color; + // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 + // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 + let requested_screen_color = payload.screen_color.clone(); + let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len); + let decision_prompt = format!( + "从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。", + payload.aspect_ratio, payload.image_size + ); let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; let reference_image = parse_editor_reference_image( state, @@ -3241,44 +3246,62 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; - let prompt = editor_ui_design_asset_extraction_prompt(screen_color); - let generated = execute_billable_asset_operation_with_cost( - state, - caller.owner_user_id.as_str(), - "editor_ui_design_asset_extraction", - request_context.request_id(), - u64::from(expected_price_mud_points), - async { - if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - create_openai_nanobanana_generate_content( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - generation_options.aspect_ratio, - generation_options.provider_image_size, - reference_images.as_slice(), - "图片画布提取UI设计图素材 spritesheet", + let (generated, prompt, screen_background_decision) = + execute_billable_asset_operation_with_cost( + state, + caller.owner_user_id.as_str(), + "editor_ui_design_asset_extraction", + request_context.request_id(), + u64::from(expected_price_mud_points), + async { + // 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。 + let screen_background_decision = resolve_editor_screen_background_color( + state.llm_client(), + state.creative_agent_gpt5_client(), + EditorScreenBackgroundDecisionInput { + kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction, + screen_color: requested_screen_color.clone(), + prompt: decision_prompt.clone(), + icon_descriptions: Vec::new(), + reference_count, + source_image_data_url: None, + }, ) - .await - } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - generation_options.size.as_str(), - 1, - reference_images.as_slice(), - "图片画布提取UI设计图素材 spritesheet", - ) - .await - } - }, - ) - .await?; + .await?; + let screen_color = screen_background_decision.color; + let prompt = editor_ui_design_asset_extraction_prompt(screen_color); + let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + generation_options.aspect_ratio, + generation_options.provider_image_size, + reference_images.as_slice(), + "图片画布提取UI设计图素材 spritesheet", + ) + .await? + } else { + create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + generation_options.size.as_str(), + 1, + reference_images.as_slice(), + "图片画布提取UI设计图素材 spritesheet", + ) + .await? + }; + Ok::<_, AppError>((generated, prompt, screen_background_decision)) + }, + ) + .await?; + let screen_color = screen_background_decision.color; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine",