动作视频背景色多色自动决策并接入阿里云抠帧

- 动画生成请求新增 screenColor,继承生图入口的 auto 决策行为,
  提示词从写死绿幕改为纯色背景条款(LLM 决策 11 色)
- 后端抽帧抠图改为逐帧阿里云优先(并发 3 保序),单帧失败降级
  本地键色算法(使用决策出的背景色)
- 清理 legacy 纯绿幕常量与提示词

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 03:50:44 +00:00
parent d0412705c4
commit ba8662f844
7 changed files with 113 additions and 45 deletions
@@ -61,10 +61,13 @@ use crate::{
editor_generation_source_entity_id, enqueue_editor_generation_job,
},
editor_green_screen::{
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
legacy_editor_green_screen_background_color,
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
remove_editor_generated_green_screen_background,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
resolve_editor_screen_background_color,
},
editor_project::{
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
@@ -564,9 +567,13 @@ pub async fn generate_editor_character_animation(
})),
)
})?;
let normalized =
normalize_editor_character_animation_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| character_animation_error_response(&request_context, error))?;
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
let normalized = normalize_editor_character_animation_request_with_pricing(
payload.clone(),
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
payload.source_layer_id.as_str(),
@@ -628,9 +635,25 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
})),
)
})?;
let normalized =
normalize_editor_character_animation_request_with_pricing(payload, &pricing)
.map_err(|error| character_animation_error_response(&request_context, error))?;
// 背景色决策:显式传入 → 继承源角色图(前端从图层 generationInputs 带入)→ LLM 自动决策 → 默认色。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
screen_color: payload.screen_color.clone(),
prompt: payload.prompt_text.clone(),
icon_descriptions: Vec::new(),
reference_count: 1,
},
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let normalized = normalize_editor_character_animation_request_with_pricing(
payload,
&pricing,
screen_background_decision.color,
)
.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 extraction_settings = resolve_backend_frame_extraction_settings(&state);
@@ -2075,10 +2098,13 @@ async fn extract_and_persist_editor_character_animation_frames(
)
.await?;
let finalized_frames = remove_editor_character_animation_frame_backgrounds(
state,
finalized_frames,
request.frame_width,
request.frame_height,
)?;
request.screen_color,
)
.await?;
let mut frame_payloads = Vec::with_capacity(finalized_frames.len());
for (index, frame) in finalized_frames.into_iter().enumerate() {
@@ -2147,30 +2173,58 @@ async fn persist_editor_character_animation_green_screen_source_frames(
Ok(())
}
fn remove_editor_character_animation_frame_backgrounds(
/// 逐帧抠图:优先阿里云通用抠图,失败降级本地键色(使用生成时的纯色背景色)。
/// 小并发保序处理,单帧降级不影响其他帧。
async fn remove_editor_character_animation_frame_backgrounds(
state: &AppState,
frames: Vec<FinalizedAnimationFrame>,
frame_width: u32,
frame_height: u32,
screen_color: EditorScreenBackgroundColor,
) -> Result<Vec<FinalizedAnimationFrame>, AppError> {
let mut removed_frames = Vec::with_capacity(frames.len());
for frame in frames {
let removed = remove_editor_generated_green_screen_background(
&DownloadedOpenAiImage {
use futures_util::{StreamExt as _, TryStreamExt as _};
const FRAME_MATTING_CONCURRENCY: usize = 3;
futures_util::stream::iter(frames.into_iter().enumerate().map(
|(frame_index, frame)| async move {
let image = DownloadedOpenAiImage {
bytes: frame.bytes,
mime_type: frame.mime_type,
extension: frame.extension,
},
legacy_editor_green_screen_background_color(),
)?;
removed_frames.push(finalize_animation_frame_payload(
removed.bytes.as_slice(),
removed.mime_type.as_str(),
frame_width,
frame_height,
false,
)?);
}
Ok(removed_frames)
};
let removed = match crate::aliyun_matting::segment_image_with_aliyun_matting(
state,
&image,
"editor-animation-frame",
)
.await
{
Ok(removed) => removed,
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
frame_index,
screen_color = screen_color.hex,
error = %error,
error_details = ?error.details(),
"editor_animation_frame_aliyun_matting_fallback_to_local"
);
remove_editor_generated_green_screen_background(&image, screen_color)?
}
};
finalize_animation_frame_payload(
removed.bytes.as_slice(),
removed.mime_type.as_str(),
frame_width,
frame_height,
false,
)
},
))
.buffered(FRAME_MATTING_CONCURRENCY)
.try_collect::<Vec<_>>()
.await
}
async fn publish_animation_set(
@@ -2835,12 +2889,17 @@ fn normalize_editor_character_animation_request(
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("默认模型定价配置必须合法");
normalize_editor_character_animation_request_with_pricing(payload, &pricing)
normalize_editor_character_animation_request_with_pricing(
payload,
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
}
fn normalize_editor_character_animation_request_with_pricing(
payload: EditorCharacterAnimationGenerateRequest,
pricing: &EditorGenerationPricingConfig,
screen_color: EditorScreenBackgroundColor,
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), "");
if source_layer_id.is_empty() {
@@ -2880,12 +2939,13 @@ fn normalize_editor_character_animation_request_with_pricing(
ratio,
resolution,
);
let prompt = build_editor_character_animation_prompt(prompt_text.as_str());
let prompt = build_editor_character_animation_prompt(prompt_text.as_str(), screen_color);
Ok(NormalizedEditorCharacterAnimationRequest {
source_layer_id,
source_image_src,
prompt,
screen_color,
resolution: resolution.to_string(),
ratio: resolve_editor_character_animation_provider_ratio(
ratio,
@@ -3385,10 +3445,13 @@ fn editor_video_bad_request(message: impl Into<String>) -> AppError {
}))
}
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
fn build_editor_character_animation_prompt(
prompt_text: &str,
screen_color: EditorScreenBackgroundColor,
) -> String {
format!(
"生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:\n{}",
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
editor_green_screen_character_prompt_clause(screen_color),
prompt_text.trim()
)
}
@@ -5091,6 +5154,7 @@ struct NormalizedEditorCharacterAnimationRequest {
frame_width: u32,
frame_height: u32,
fps: u32,
screen_color: EditorScreenBackgroundColor,
}
#[derive(Debug)]
@@ -5319,6 +5383,7 @@ mod tests {
source_width: 768,
source_height: 1024,
prompt_text: "待机呼吸,轻微摆动。".to_string(),
screen_color: None,
resolution: "720p".to_string(),
ratio: "same".to_string(),
frame_count: 48,
@@ -5357,6 +5422,7 @@ mod tests {
source_width: 1024,
source_height: 1024,
prompt_text: "待机呼吸".to_string(),
screen_color: None,
resolution: "480p".to_string(),
ratio: "same".to_string(),
frame_count: 32,
@@ -5384,6 +5450,7 @@ mod tests {
source_width: 1024,
source_height: 1024,
prompt_text: "奔跑".to_string(),
screen_color: None,
resolution: "480p".to_string(),
ratio: "1:1".to_string(),
frame_count: 48,
@@ -5400,14 +5467,15 @@ mod tests {
}
#[test]
fn editor_character_animation_builds_required_green_screen_prompt() {
let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。");
fn editor_character_animation_builds_required_screen_background_prompt() {
let color = crate::editor_green_screen::default_editor_screen_background_color();
let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。", color);
assert!(prompt.contains("生成游戏角色动画"));
assert!(prompt.contains("参考图作为首帧和尾帧"));
assert!(prompt.contains("背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕"));
assert!(prompt.contains("绿幕背景必须平整无纹理、无渐变、无阴影"));
assert!(prompt.contains("角色主体不得带绿色描边、绿色投影或绿色反光"));
assert!(prompt.contains(color.hex));
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
assert!(prompt.contains("角色主体不得带与背景色相同或相近的描边、投影或反光"));
assert!(prompt.contains("动作描述:\n行走两步后回到站姿。"));
}
@@ -100,15 +100,6 @@ pub(crate) fn default_editor_screen_background_color() -> EditorScreenBackground
EDITOR_SCREEN_BACKGROUND_COLORS[0]
}
pub(crate) fn legacy_editor_green_screen_background_color() -> EditorScreenBackgroundColor {
EditorScreenBackgroundColor {
label: "纯绿色",
hex: "#00FF00",
red: 0,
green: 255,
blue: 0,
}
}
pub(crate) fn parse_editor_screen_background_color(
value: Option<&str>,
@@ -139,7 +130,6 @@ pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
"素材自身不要出现与背景色相同或相近的描边、底板、投影或反光";
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
"角色主体不得带与背景色相同或相近的描边、投影或反光";
pub(crate) const LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE: &str = "背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光";
fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> String {
if color.hex == "#00FF00" {
@@ -31,6 +31,7 @@ pub(crate) struct EditorScreenBackgroundDecision {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum EditorScreenBackgroundDecisionKind {
Character,
CharacterAnimation,
IconSpritesheet,
UiDesignAssetExtraction,
}
@@ -39,6 +40,7 @@ impl EditorScreenBackgroundDecisionKind {
fn label(self) -> &'static str {
match self {
Self::Character => "角色形象生成",
Self::CharacterAnimation => "角色动作视频生成",
Self::IconSpritesheet => "图标素材 spritesheet 生成",
Self::UiDesignAssetExtraction => "UI 设计图素材提取",
}
@@ -314,6 +314,9 @@ pub struct EditorCharacterAnimationGenerateRequest {
pub source_width: u32,
pub source_height: u32,
pub prompt_text: String,
/// 纯色抠像背景色(hex)。None/auto 走自动决策;通常继承源角色图的背景色。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_color: Option<String>,
pub resolution: String,
pub ratio: String,
pub frame_count: u32,
@@ -726,6 +726,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
sourceWidth: 960,
sourceHeight: 1280,
promptText: '循环奔跑动作',
screenColor: 'auto',
resolution: '720p',
ratio: 'same',
frameCount: 48,
@@ -649,6 +649,8 @@ export function buildCharacterAnimationSubmissionPlan({
sourceWidth: sourceLayer.originalWidth,
sourceHeight: sourceLayer.originalHeight,
promptText,
// 与生图入口一致:默认 auto,由后端做背景色自动决策。
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
resolution: panel.resolution,
ratio: panel.ratio,
frameCount: panel.frameCount,
@@ -328,6 +328,8 @@ export type EditorCharacterAnimationGenerationInput = {
sourceWidth: number;
sourceHeight: number;
promptText: string;
/** 纯色抠像背景色(hex 或 'auto'),继承生图的背景色选项行为。 */
screenColor?: string;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;