背景色决策升级:视觉选色 + 皮肤硬过滤 + 源图合成 + 结构化记录

修复动作视频背景色的三类真实事故并加确定性防线:

1. 视觉背景色决策:让 LLM 看参考图选背景色(gpt-4o-mini 视觉模型,
   路由到 VectorEngine 视觉客户端),修复原纯文本盲选导致的"蓝撞蓝"。

2. 背景色候选硬过滤器(新增 editor_screen_background_filter):
   按参考图前景配色算 Lab 危险质量 + 皮肤专属三判据(ΔE 距离、
   色调投影、RGB 分离)剔除撞色候选,LLM 只在安全集合里审美选。
   露肤角色自动收敛到冷区。新增"灰竹绿"候选补齐安全弧绿色段。

3. 源图合成到背景色:透明源图先填成选定实色再发给 Ark,视频背景
   确定性等于抠图键色,不再赌模型服从提示词(修复背景变白)。

4. screenColorHex 结构化记录:四条链路统一把实际背景色写进
   generation_inputs,并修复生图链路"抠图背景色"字段从未生效的旧逻辑。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 13:37:44 +00:00
parent 77d33af651
commit 923993ea6c
7 changed files with 1075 additions and 45 deletions
@@ -70,6 +70,7 @@ use crate::{
},
editor_project::{
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
apply_editor_screen_background_decision_to_generation_inputs,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
},
@@ -108,6 +109,7 @@ const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION: &str = "480p";
const FIXED_ARK_CHARACTER_VIDEO_RATIO: &str = "1:1";
const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4;
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
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_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
@@ -635,19 +637,37 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
})),
)
})?;
// 背景色决策:显式传入 → 继承源角色图(前端从图层 generationInputs 带入)→ LLM 自动决策 → 默认色
// 先解析源角色图:图生视频的主体颜色完全由源图决定,背景色决策必须看到这张图
let source_resolve_client = build_upstream_http_client(SOURCE_IMAGE_RESOLVE_TIMEOUT_MS)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_data_url = resolve_media_source_as_data_url(
&state,
&source_resolve_client,
payload.source_image_src.trim(),
"sourceImageSrc",
)
.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,
&pricing,
@@ -656,18 +676,16 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
.map_err(|error| character_animation_error_response(&request_context, error))?;
let settings = require_editor_character_animation_settings(&state, &normalized)
.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 task_id = generate_ai_task_id(current_utc_micros());
let source_data_url = resolve_media_source_as_data_url(
&state,
&http_client,
normalized.source_image_src.as_str(),
"sourceImageSrc",
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let result = execute_billable_asset_operation_with_cost(
&state,
@@ -2907,7 +2925,8 @@ fn normalize_editor_character_animation_request_with_pricing(
"sourceLayerId 不能为空。",
));
}
let source_image_src = trim_optional_text(Some(payload.source_image_src.as_str()))
// 源图在 handler 里已提前解析(决策要看图);这里保留空值校验,队列路径入队前也走这份校验。
trim_optional_text(Some(payload.source_image_src.as_str()))
.ok_or_else(|| editor_character_animation_bad_request("sourceImageSrc 不能为空。"))?;
let prompt_text = payload
.prompt_text
@@ -2943,7 +2962,6 @@ fn normalize_editor_character_animation_request_with_pricing(
Ok(NormalizedEditorCharacterAnimationRequest {
source_layer_id,
source_image_src,
prompt,
screen_color,
resolution: resolution.to_string(),
@@ -4067,6 +4085,68 @@ fn parse_media_data_url(value: &str) -> Option<MediaPayload> {
})
}
/// 把带透明通道的源图合成到选定背景色上,返回新的 PNG data URL。
/// 视频模型不支持 alpha:透明源图会被按白底理解,且图生视频里源图背景的视觉证据
/// 权重高于文字提示,导致提示词指定的背景色被无视。先填成实色让源图和提示词一致,
/// 视频背景就是抠图键色。源图本身不透明或无法解码时返回 None(沿用原图)。
fn composite_source_image_onto_screen_color(
source_data_url: &str,
screen_color: EditorScreenBackgroundColor,
) -> Option<String> {
let payload = parse_media_data_url(source_data_url)?;
if !payload.mime_type.starts_with("image/") {
return None;
}
let Ok(image) = image::load_from_memory(payload.bytes.as_slice()) else {
tracing::warn!("character_animation_source_composite_skip_undecodable_image");
return None;
};
let rgba = image.to_rgba8();
if rgba.pixels().all(|pixel| pixel.0[3] == u8::MAX) {
return None;
}
let background = [
f32::from(screen_color.red),
f32::from(screen_color.green),
f32::from(screen_color.blue),
];
let mut composited = RgbaImage::new(rgba.width(), rgba.height());
for (x, y, pixel) in rgba.enumerate_pixels() {
let [red, green, blue, alpha] = pixel.0;
let opacity = f32::from(alpha) / 255.0;
let blend = |source: u8, background: f32| -> u8 {
(f32::from(source) * opacity + background * (1.0 - opacity)).round() as u8
};
composited.put_pixel(
x,
y,
Rgba([
blend(red, background[0]),
blend(green, background[1]),
blend(blue, background[2]),
u8::MAX,
]),
);
}
let mut bytes = Vec::new();
if PngEncoder::new(&mut bytes)
.write_image(
composited.as_raw(),
composited.width(),
composited.height(),
ColorType::Rgba8.into(),
)
.is_err()
{
tracing::warn!("character_animation_source_composite_skip_encode_failed");
return None;
}
Some(format!(
"data:image/png;base64,{}",
encode_base64(bytes.as_slice())
))
}
fn resolve_object_key_from_legacy_path(value: &str, field: &str) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -5144,7 +5224,6 @@ struct EditorVideoSettings {
#[derive(Debug)]
struct NormalizedEditorCharacterAnimationRequest {
source_layer_id: String,
source_image_src: String,
prompt: String,
resolution: String,
ratio: String,
@@ -5247,6 +5326,56 @@ mod tests {
assert!(parse_video_data_url("data:image/png;base64,aGVsbG8=").is_none());
}
fn encode_rgba_png_data_url(image: &RgbaImage) -> String {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(
image.as_raw(),
image.width(),
image.height(),
ColorType::Rgba8.into(),
)
.expect("test image should encode");
format!("data:image/png;base64,{}", encode_base64(bytes.as_slice()))
}
#[test]
fn composites_transparent_source_onto_screen_color() {
let mut image = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 0]));
image.put_pixel(4, 4, Rgba([10, 20, 30, 255]));
let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0];
let composited =
composite_source_image_onto_screen_color(&encode_rgba_png_data_url(&image), screen_color)
.expect("透明源图应被合成");
let payload = parse_media_data_url(&composited).expect("合成结果应是图片 data URL");
let output = image::load_from_memory(payload.bytes.as_slice())
.expect("合成结果应可解码")
.to_rgba8();
assert_eq!(
output.get_pixel(0, 0).0,
[screen_color.red, screen_color.green, screen_color.blue, 255],
"透明像素应填成背景色"
);
assert_eq!(output.get_pixel(4, 4).0, [10, 20, 30, 255], "不透明像素应保持原色");
}
#[test]
fn opaque_source_image_skips_compositing() {
let image = RgbaImage::from_pixel(4, 4, Rgba([1, 2, 3, 255]));
let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0];
assert!(
composite_source_image_onto_screen_color(
&encode_rgba_png_data_url(&image),
screen_color
)
.is_none(),
"不透明源图应沿用原图"
);
}
#[test]
fn sanitize_storage_segment_falls_back_for_chinese_label() {
assert_eq!(
@@ -5475,7 +5604,9 @@ mod tests {
assert!(prompt.contains("参考图作为首帧和尾帧"));
assert!(prompt.contains(color.hex));
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
assert!(prompt.contains("角色主体不得带与背景色相同或相近的描边、投影或反光"));
assert!(
prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光")
);
assert!(prompt.contains("动作描述:\n行走两步后回到站姿。"));
}
@@ -16,7 +16,7 @@ pub(crate) struct EditorScreenBackgroundColor {
pub(crate) blue: u8,
}
pub(crate) const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 11] = [
pub(crate) const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 12] = [
EditorScreenBackgroundColor {
label: "浅雾蓝",
hex: "#CFEFFF",
@@ -94,6 +94,15 @@ pub(crate) const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor;
green: 247,
blue: 240,
},
// 中明度低饱和柔雾绿:填补安全弧的绿色段。浅粉彩绿(淡薄荷绿)会被露肤角色的皮肤规则剔除,
// 故绿色改用中明度以过 RGB 分离(Rule 3);色相远离皮肤橙调,过色调投影(Rule 2)。
EditorScreenBackgroundColor {
label: "灰竹绿",
hex: "#A0BBA0",
red: 160,
green: 187,
blue: 160,
},
];
pub(crate) fn default_editor_screen_background_color() -> EditorScreenBackgroundColor {
@@ -127,9 +136,9 @@ pub(crate) fn parse_editor_screen_background_color(
pub(crate) const EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS: &str =
"纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具";
pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
"素材自身不要出现与背景色相同或相近的描边、底板、投影或反光";
"素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光";
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
"角色主体不得带与背景色相同或相近的描边、投影或反光";
"角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光";
fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> String {
if color.hex == "#00FF00" {
@@ -1348,12 +1348,14 @@ pub(crate) async fn generate_editor_image_for_owner(
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?,
@@ -1636,25 +1638,31 @@ fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) ->
}
}
fn apply_editor_screen_background_decision_to_generation_inputs(
pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs(
generation_inputs: Option<Value>,
decision: Option<&EditorScreenBackgroundDecision>,
) -> Option<Value> {
let Some(decision) = decision else {
return generation_inputs;
};
let mut value = generation_inputs?;
let Some(fields) = value.get_mut("fields").and_then(Value::as_array_mut) else {
return Some(value);
let mut value = match generation_inputs {
Some(value) if value.is_object() => value,
Some(other) => return Some(other),
None => json!({ "fields": [], "references": [] }),
};
let resolved_value = format_editor_screen_background_decision_input(decision);
for field in fields {
let title = field.get("title").and_then(Value::as_str);
if title == Some("抠图背景色") {
if let Some(fields) = value.get_mut("fields").and_then(Value::as_array_mut) {
if let Some(field) = fields
.iter_mut()
.find(|field| field.get("title").and_then(Value::as_str) == Some("抠图背景色"))
{
field["value"] = Value::String(resolved_value);
break;
} else {
fields.push(json!({ "title": "抠图背景色", "value": resolved_value }));
}
}
// 结构化背景色,供旧背景色先验等程序化消费;展示字段的中文格式不可作解析依据。
value["screenColorHex"] = Value::String(decision.color.hex.to_string());
Some(value)
}
@@ -2727,12 +2735,14 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
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?;
@@ -2994,6 +3004,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
) -> 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(),
@@ -3003,6 +3014,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
),
icon_descriptions: Vec::new(),
reference_count: 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len),
source_image_data_url: None,
},
)
.await?;
@@ -5525,6 +5537,69 @@ mod tests {
);
}
fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision {
EditorScreenBackgroundDecision {
color: parse_editor_screen_background_color(Some(hex))
.expect("hex should be a known background color candidate"),
mode: crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual,
attempts: 0,
fallback: false,
}
}
#[test]
fn screen_background_decision_generation_inputs_record_structured_hex() {
let decision = manual_screen_background_decision("#B0C2E0");
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["screenColorHex"], json!("#B0C2E0"));
assert_eq!(
updated["fields"],
json!([
{ "title": "角色设定", "value": "红发骑士" },
{ "title": "抠图背景色", "value": "浅钢蓝 #B0C2E0" },
]),
);
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": [{ "title": "抠图背景色", "value": "浅钢蓝 #B0C2E0" }],
"references": [],
"screenColorHex": "#B0C2E0",
}),
);
}
#[test]
fn screen_background_decision_generation_inputs_overwrite_existing_display_field() {
let decision = manual_screen_background_decision("#CFEFFF");
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": "浅雾蓝 #CFEFFF" }]),
);
assert_eq!(updated["screenColorHex"], json!("#CFEFFF"));
}
fn apply_editor_canvas_generation_completion(
layers: Value,
completion: &EditorCanvasGenerationCompletionRequest,
@@ -6195,7 +6270,9 @@ mod tests {
assert!(prompt.contains("生成游戏角色立绘"));
assert!(prompt.contains("背景固定为单一纯色背景 暖浅桃色 #FFD6C2 / RGB(255,214,194)"));
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
assert!(prompt.contains("角色主体不得带与背景色相同或相近的描边、投影或反光"));
assert!(
prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光")
);
assert!(prompt.contains("禁止镜头透视"));
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
}
@@ -6737,7 +6814,9 @@ mod tests {
assert!(prompt.contains("参考图1的图标素材规范"));
assert!(prompt.contains("背景必须是单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255)"));
assert!(prompt.contains("平整无纹理、无渐变、无阴影"));
assert!(prompt.contains("素材自身不要出现与背景色相同或相近的描边、底板、投影或反光"));
assert!(
prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光")
);
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
}
@@ -6759,7 +6838,7 @@ mod tests {
parse_editor_screen_background_color(None)
.expect("default screen color should parse")
),
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。"
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光。"
);
}
@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmTextRequest};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::json;
use tracing::{info, warn};
@@ -7,6 +7,10 @@ use crate::{
EDITOR_SCREEN_BACKGROUND_COLORS, EditorScreenBackgroundColor,
default_editor_screen_background_color, parse_editor_screen_background_color,
},
editor_screen_background_filter::{
ScreenBackgroundColorSafetyReport, decode_image_data_url,
filter_editor_screen_background_colors, safety_report_log_json,
},
http_error::AppError,
};
@@ -54,11 +58,15 @@ pub(crate) struct EditorScreenBackgroundDecisionInput {
pub(crate) prompt: String,
pub(crate) icon_descriptions: Vec<String>,
pub(crate) reference_count: usize,
/// 主体参考图(data URL)。图生视频等主体颜色由参考图决定的场景必须传,
/// 让视觉 LLM 按主体实际配色避开撞色背景;纯文生图场景可为 None。
pub(crate) source_image_data_url: Option<String>,
}
pub(crate) async fn resolve_editor_screen_background_color(
llm_client: Option<&LlmClient>,
input: EditorScreenBackgroundDecisionInput,
vision_llm_client: Option<&LlmClient>,
mut input: EditorScreenBackgroundDecisionInput,
) -> Result<EditorScreenBackgroundDecision, AppError> {
if !is_auto_screen_background_color(input.screen_color.as_deref()) {
return Ok(EditorScreenBackgroundDecision {
@@ -69,7 +77,50 @@ pub(crate) async fn resolve_editor_screen_background_color(
});
}
let fallback_color = default_editor_screen_background_color();
let has_source_image = input
.source_image_data_url
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
// 硬过滤先于 LLM:按参考图前景配色剔除撞色候选,LLM 只在安全集合里做审美选择。
// 分析失败(图片不可解码、前景不足)只降级为不过滤,不阻断生成。
let safety_report = if has_source_image {
resolve_screen_background_safety_report(&input)
} else {
None
};
let candidate_colors: Vec<EditorScreenBackgroundColor> = safety_report
.as_ref()
.map(|report| report.allowed.clone())
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
// 默认 llm_client 是纯文本模型(Ark),收到图片分片会被上游 400 拒绝;
// 带图决策必须走 VectorEngine 视觉客户端,没有视觉客户端时降级为纯文本决策。
let (llm_client, vision_model) = if has_source_image {
match vision_llm_client {
Some(client) => (
Some(client),
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL),
),
None => {
warn!(
kind = input.kind.label(),
"editor_screen_background_vision_client_missing_downgrade_to_text"
);
input.source_image_data_url = None;
(llm_client, None)
}
}
} else {
(llm_client, None)
};
// 默认兜底色被过滤掉时,改用危险度最小的候选兜底。
let default_color = default_editor_screen_background_color();
let fallback_color = match safety_report.as_ref() {
Some(report) if !report.contains(default_color.hex) => report.safest,
_ => default_color,
};
let Some(llm_client) = llm_client else {
warn!(
kind = input.kind.label(),
@@ -85,15 +136,33 @@ pub(crate) async fn resolve_editor_screen_background_color(
};
let system_prompt = editor_screen_background_decision_system_prompt();
let user_prompt = editor_screen_background_decision_user_prompt(&input);
let user_prompt =
editor_screen_background_decision_user_prompt(&input, &candidate_colors, fallback_color);
let source_image_data_url = input
.source_image_data_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let mut last_error: Option<String> = None;
for attempt in 1..=EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS {
let request = LlmTextRequest::single_turn(system_prompt, user_prompt.as_str())
let user_message = match source_image_data_url {
Some(image_url) => {
LlmMessage::user(user_prompt.as_str()).with_image_url(image_url)
}
None => LlmMessage::user(user_prompt.as_str()),
};
let mut request = LlmTextRequest::new(vec![LlmMessage::system(system_prompt), user_message])
.with_max_tokens(96)
.with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS);
if let Some(vision_model) = vision_model {
request = request.with_model(vision_model);
}
match llm_client.request_text(request).await {
Ok(response) => {
match parse_editor_screen_background_decision_response(response.content.as_str()) {
match parse_editor_screen_background_decision_response(
response.content.as_str(),
&candidate_colors,
) {
Some(color) => {
info!(
kind = input.kind.label(),
@@ -148,6 +217,51 @@ pub(crate) async fn resolve_editor_screen_background_color(
})
}
/// 解出参考图并做候选色硬过滤;任何一步失败都返回 None(降级为不过滤)。
fn resolve_screen_background_safety_report(
input: &EditorScreenBackgroundDecisionInput,
) -> Option<ScreenBackgroundColorSafetyReport> {
let data_url = input.source_image_data_url.as_deref()?.trim();
let Some(image_bytes) = decode_image_data_url(data_url) else {
warn!(
kind = input.kind.label(),
"editor_screen_background_filter_skip_undecodable_data_url"
);
return None;
};
// 角色/动作类启用皮肤专属硬否决(皮肤恒暖恒在,是约束最紧的前景);图标/UI 类只跑统计过滤。
let apply_skin_veto = matches!(
input.kind,
EditorScreenBackgroundDecisionKind::Character
| EditorScreenBackgroundDecisionKind::CharacterAnimation
);
match filter_editor_screen_background_colors(&image_bytes, apply_skin_veto) {
Ok(report) => {
if report.excluded.is_empty() {
info!(
kind = input.kind.label(),
"editor_screen_background_filter_no_exclusion"
);
} else {
info!(
kind = input.kind.label(),
result = %safety_report_log_json(&report),
"editor_screen_background_filter_excluded_colliding_colors"
);
}
Some(report)
}
Err(reason) => {
warn!(
kind = input.kind.label(),
reason = reason.as_str(),
"editor_screen_background_filter_skip_analysis_failed"
);
None
}
}
}
pub(crate) fn is_auto_screen_background_color(value: Option<&str>) -> bool {
value
.map(str::trim)
@@ -174,13 +288,15 @@ pub(crate) fn format_editor_screen_background_decision_input(
}
fn editor_screen_background_decision_system_prompt() -> &'static str {
"你是游戏素材生图的抠图背景色决策器。根据任务和用户输入,从候选列表中选择一个最不容易与主体混淆、最适合后续扣除的纯色背景。只能返回 JSON:{\"hex\":\"#RRGGBB\"}。不要解释,不要返回候选列表外的颜色。"
"你是游戏素材生图的抠图背景色决策器。根据任务和用户输入,从候选列表中选择一个最不容易与主体混淆、最适合后续扣除的纯色背景。如果附带了主体参考图,必须以图中主体(含服饰、道具、光效)的实际配色为准,选择色相距离最远的候选色,忽略图中已有的背景或透明区域。只能返回 JSON{\"hex\":\"#RRGGBB\"}。不要解释,不要返回候选列表外的颜色。"
}
fn editor_screen_background_decision_user_prompt(
input: &EditorScreenBackgroundDecisionInput,
candidate_colors: &[EditorScreenBackgroundColor],
fallback_color: EditorScreenBackgroundColor,
) -> String {
let colors = EDITOR_SCREEN_BACKGROUND_COLORS
let colors = candidate_colors
.iter()
.map(|color| {
format!(
@@ -190,16 +306,23 @@ fn editor_screen_background_decision_user_prompt(
})
.collect::<Vec<_>>()
.join("\n");
let has_source_image = input
.source_image_data_url
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let context = json!({
"task": input.kind.label(),
"userPrompt": input.prompt,
"iconDescriptions": input.icon_descriptions,
"referenceCount": input.reference_count,
"hasSourceImage": has_source_image,
"selectionCriteria": [
"优先选择与主体、主题色、UI 面板底色、发光效果和描边明显区分的背景色",
"避免选择可能出现在主体、服饰、道具、按钮、图标或 UI 面板里的颜色",
"有主体参考图时以图中主体实际配色为准,文本描述只作辅助",
"角色类优先低干扰浅色,图标和 UI 提取类优先更高对比但仍保持纯净",
"信息不足时选择浅雾蓝 #CFEFFF"
format!("信息不足时选择{} {}", fallback_color.label, fallback_color.hex)
],
});
format!("候选背景色:\n{colors}\n\n任务上下文 JSON\n{context}")
@@ -207,27 +330,32 @@ fn editor_screen_background_decision_user_prompt(
fn parse_editor_screen_background_decision_response(
content: &str,
candidate_colors: &[EditorScreenBackgroundColor],
) -> Option<EditorScreenBackgroundColor> {
let trimmed = content.trim();
let json_source = extract_json_object(trimmed).unwrap_or(trimmed);
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json_source) {
if let Some(hex) = value.get("hex").and_then(serde_json::Value::as_str) {
return find_editor_screen_background_color(hex);
return find_editor_screen_background_color(candidate_colors, hex);
}
if let Some(hex) = value
.get("screenColor")
.or_else(|| value.get("screen_color"))
.and_then(serde_json::Value::as_str)
{
return find_editor_screen_background_color(hex);
return find_editor_screen_background_color(candidate_colors, hex);
}
}
find_hex_in_text(trimmed).and_then(find_editor_screen_background_color)
find_hex_in_text(trimmed)
.and_then(|hex| find_editor_screen_background_color(candidate_colors, hex))
}
fn find_editor_screen_background_color(value: &str) -> Option<EditorScreenBackgroundColor> {
fn find_editor_screen_background_color(
candidate_colors: &[EditorScreenBackgroundColor],
value: &str,
) -> Option<EditorScreenBackgroundColor> {
let normalized = value.trim().to_ascii_uppercase();
EDITOR_SCREEN_BACKGROUND_COLORS
candidate_colors
.iter()
.copied()
.find(|color| color.hex == normalized)
@@ -266,6 +394,7 @@ mod tests {
#[tokio::test]
async fn manual_screen_background_decision_keeps_selected_color() {
let decision = resolve_editor_screen_background_color(
None,
None,
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::Character,
@@ -273,6 +402,7 @@ mod tests {
prompt: "绿色史莱姆".to_string(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: None,
},
)
.await
@@ -286,6 +416,7 @@ mod tests {
#[tokio::test]
async fn auto_screen_background_decision_falls_back_without_llm() {
let decision = resolve_editor_screen_background_color(
None,
None,
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
@@ -293,6 +424,7 @@ mod tests {
prompt: String::new(),
icon_descriptions: vec!["金币按钮".to_string()],
reference_count: 2,
source_image_data_url: None,
},
)
.await
@@ -306,6 +438,7 @@ mod tests {
#[tokio::test]
async fn missing_screen_background_decision_uses_auto_default() {
let decision = resolve_editor_screen_background_color(
None,
None,
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction,
@@ -313,6 +446,7 @@ mod tests {
prompt: "提取 UI 按钮".to_string(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: None,
},
)
.await
@@ -325,9 +459,11 @@ mod tests {
#[test]
fn parses_llm_json_background_decision() {
let color =
parse_editor_screen_background_decision_response("```json\n{\"hex\":\"#a8f7f0\"}\n```")
.expect("known color should parse");
let color = parse_editor_screen_background_decision_response(
"```json\n{\"hex\":\"#a8f7f0\"}\n```",
&EDITOR_SCREEN_BACKGROUND_COLORS,
)
.expect("known color should parse");
assert_eq!(color.label, "高对比浅青");
}
@@ -335,7 +471,68 @@ mod tests {
#[test]
fn rejects_unknown_llm_background_decision() {
assert!(
parse_editor_screen_background_decision_response("{\"hex\":\"#123456\"}").is_none()
parse_editor_screen_background_decision_response(
"{\"hex\":\"#123456\"}",
&EDITOR_SCREEN_BACKGROUND_COLORS,
)
.is_none()
);
}
#[test]
fn rejects_filtered_out_llm_background_decision() {
// LLM 返回了被硬过滤剔除的颜色(不在候选集里)时按无效响应处理。
let allowed = [EDITOR_SCREEN_BACKGROUND_COLORS[0]];
assert!(
parse_editor_screen_background_decision_response("{\"hex\":\"#7FB3FF\"}", &allowed)
.is_none()
);
}
#[tokio::test]
async fn auto_decision_fallback_avoids_colliding_default_color() {
use base64::Engine as _;
use image::{Rgba, RgbaImage};
// 前景是与默认兜底色(浅雾蓝 #CFEFFF)同色的实心块:
// 无 LLM 时兜底不能再落在被硬过滤剔除的默认色上。
let mut image = RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 0]));
for y in 16..48 {
for x in 16..48 {
image.put_pixel(x, y, Rgba([207, 239, 255, 255]));
}
}
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("test image should encode");
let data_url = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&bytes)
);
let decision = resolve_editor_screen_background_color(
None,
None,
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
screen_color: Some("auto".to_string()),
prompt: "起跳动作".to_string(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: Some(data_url),
},
)
.await
.expect("decision should fall back");
assert!(decision.fallback);
assert_ne!(
decision.color.hex, "#CFEFFF",
"默认兜底色与前景撞色时应改用危险度最小的候选"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128";
pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201";
pub(crate) const PUZZLE_LEVEL_NAME_VISION_LLM_MODEL: &str = "gpt-4o-mini";
// 抠图背景色决策的视觉模型:只需看图选色,用低成本视觉模型即可。
pub(crate) const EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL: &str = "gpt-4o-mini";
+1
View File
@@ -43,6 +43,7 @@ mod editor_generation_queue;
mod editor_green_screen;
mod editor_project;
mod editor_screen_background_decision;
mod editor_screen_background_filter;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;