动作视频背景色多色自动决策并接入阿里云抠帧
- 动画生成请求新增 screenColor,继承生图入口的 auto 决策行为, 提示词从写死绿幕改为纯色背景条款(LLM 决策 11 色) - 后端抽帧抠图改为逐帧阿里云优先(并发 3 保序),单帧失败降级 本地键色算法(使用决策出的背景色) - 清理 legacy 纯绿幕常量与提示词 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user