背景色决策挪到预扣泥点之后,避免余额不足/失败时白发决策

生图 / 动作生视频 / 图标 spritesheet / UI 提取四条链路此前都在扣费前跑 gpt-5-mini
背景色决策,导致用户余额不足或生成注定失败时仍白发一次决策,平台白付 token。

改为:预扣前只做颜色无关的算价 / 校验 / settings(动画用默认色占位算价,与队列
路径一致),把决策及颜色相关的 prompt / 合成 / generation_inputs 搬进
execute_billable_asset_operation_with_cost 的闭包,闭包把决策结果带出供后续抠图
与落库使用。语义:余额不足则决策不跑(平台零成本);决策失败则闭包返 Err 走失败
退款(用户不损失泥点)。四条 _for_owner 函数同时覆盖 worker / inline / agent /
external-API 所有入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:02:20 +00:00
parent 899687e6e0
commit 25d5baed8f
2 changed files with 224 additions and 193 deletions
@@ -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| {
+182 -159
View File
@@ -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::<Vec<_>>();
// 决策移入闭包后 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<Json<Value>, 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<Json<Value>, 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",