背景色决策升级:视觉选色 + 皮肤硬过滤 + 源图合成 + 结构化记录
修复动作视频背景色的三类真实事故并加确定性防线: 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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user