明确序列帧去背景处理模式

去背景弹窗要求用户显式选择纯色键控或通用智能分割。

纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。

通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。

入队与 durable worker 统一校验模式参数并持久化权威处理元数据。

补充双色键控、模式冲突、双路径路由和前端交互回归。

同步更新 Spine 技术方案与团队决策记录。
This commit is contained in:
2026-08-17 15:27:13 +08:00
parent ae5c2d8821
commit 3d145379f2
13 changed files with 665 additions and 33 deletions
@@ -43,6 +43,7 @@ use shared_contracts::assets::{
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
EditorCharacterAnimationBackgroundRemovalMode,
EditorCharacterAnimationBackgroundRemovalRequest,
EditorCharacterAnimationBackgroundRemovalResponse, EditorCharacterAnimationFramePayload,
EditorCharacterAnimationFrameSplitRequest, EditorCharacterAnimationFrameSplitResponse,
@@ -85,13 +86,13 @@ use crate::{
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
},
editor_green_screen::{
EditorScreenBackgroundColor, remove_editor_character_animation_green_screen_background,
EditorScreenBackgroundColor, remove_editor_character_animation_solid_color_background,
},
editor_project::{
EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_LOCAL_MATTING_PROVIDER,
EDITOR_RESOURCE_ID_PREFIX, EditorCanvasGeneratedLayerInput, EditorCanvasLayoutPlan,
EditorGenerationCaller, EditorGenerationOperationContext,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_MATTING_PROVIDER,
EDITOR_LOCAL_MATTING_PROVIDER, EDITOR_RESOURCE_ID_PREFIX, EditorCanvasGeneratedLayerInput,
EditorCanvasLayoutPlan, EditorGenerationCaller, EditorGenerationOperationContext,
PreparedEditorGenerationResultItem, build_editor_canvas_generated_layer_item,
download_editor_persisted_image_object, editor_asset_payload_from_record,
editor_media_src_from_object_key, editor_project_payload_from_record,
@@ -100,6 +101,7 @@ use crate::{
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
rebuild_editor_generation_input_references,
remove_editor_generated_screen_background_with_bgfilter,
request_editor_background_removal_image_with_bgfilter_worker,
resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs,
serialize_editor_generation_inputs, serialize_editor_image_sequence_frames,
validate_editor_generation_operation_id, with_editor_media_duration_generation_input,
@@ -1140,6 +1142,118 @@ fn editor_character_animation_sequence_is_video_conversion(
&& inputs.get("action").and_then(Value::as_str) == Some("character-animation.convert")
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct NormalizedEditorCharacterAnimationBackgroundRemoval {
mode: EditorCharacterAnimationBackgroundRemovalMode,
screen_color: Option<String>,
key_rgb: Option<[u8; 3]>,
}
fn normalize_editor_character_animation_background_removal(
payload: &EditorCharacterAnimationBackgroundRemovalRequest,
) -> Result<NormalizedEditorCharacterAnimationBackgroundRemoval, AppError> {
match payload.mode {
EditorCharacterAnimationBackgroundRemovalMode::SolidColor => {
let screen_color = payload
.screen_color
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
editor_character_animation_bad_request("纯色背景抠图必须提供 screenColor。")
})?;
let key_rgb = parse_editor_character_animation_screen_color(screen_color)?;
Ok(NormalizedEditorCharacterAnimationBackgroundRemoval {
mode: payload.mode,
screen_color: Some(screen_color.to_ascii_uppercase()),
key_rgb: Some(key_rgb),
})
}
EditorCharacterAnimationBackgroundRemovalMode::General => {
if payload.screen_color.is_some() {
return Err(editor_character_animation_bad_request(
"通用智能抠图不能携带 screenColor。",
));
}
Ok(NormalizedEditorCharacterAnimationBackgroundRemoval {
mode: payload.mode,
screen_color: None,
key_rgb: None,
})
}
}
}
fn parse_editor_character_animation_screen_color(value: &str) -> Result<[u8; 3], AppError> {
let bytes = value.as_bytes();
if bytes.len() != 7 || bytes[0] != b'#' || !bytes[1..].iter().all(u8::is_ascii_hexdigit) {
return Err(editor_character_animation_bad_request(
"screenColor 必须是 #RRGGBB。",
));
}
let parse_channel = |start: usize| {
u8::from_str_radix(&value[start..start + 2], 16)
.map_err(|_| editor_character_animation_bad_request("screenColor 必须是 #RRGGBB。"))
};
Ok([parse_channel(1)?, parse_channel(3)?, parse_channel(5)?])
}
fn apply_editor_character_animation_background_removal_metadata(
generation_inputs: Option<Value>,
options: &NormalizedEditorCharacterAnimationBackgroundRemoval,
) -> Value {
let mut inputs = generation_inputs
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
inputs.insert("version".to_string(), json!(2));
inputs.insert(
"action".to_string(),
json!("character-animation.remove-background"),
);
let mut fields = inputs
.remove("fields")
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
fields.retain(|field| {
!matches!(
field.get("id").and_then(Value::as_str),
Some("backgroundRemovalMode" | "screenColor" | "mattingProvider" | "mattingModel")
)
});
fields.push(json!({
"id": "backgroundRemovalMode",
"title": "去背景方式",
"value": match options.mode {
EditorCharacterAnimationBackgroundRemovalMode::SolidColor => "solid-color",
EditorCharacterAnimationBackgroundRemovalMode::General => "general",
},
}));
if let Some(screen_color) = options.screen_color.as_deref() {
fields.push(json!({
"id": "screenColor",
"title": "背景色",
"value": screen_color,
}));
}
fields.push(json!({
"id": "mattingProvider",
"title": "处理服务",
"value": match options.mode {
EditorCharacterAnimationBackgroundRemovalMode::SolidColor => EDITOR_LOCAL_MATTING_PROVIDER,
EditorCharacterAnimationBackgroundRemovalMode::General => EDITOR_BGFILTER_MATTING_PROVIDER,
},
}));
if options.mode == EditorCharacterAnimationBackgroundRemovalMode::General {
fields.push(json!({
"id": "mattingModel",
"title": "抠图模型",
"value": EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
}));
}
inputs.insert("fields".to_string(), Value::Array(fields));
Value::Object(inputs)
}
pub async fn remove_editor_character_animation_background(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1186,6 +1300,9 @@ pub(crate) async fn enqueue_editor_character_animation_background_removal_for_ow
owner_user_id: &str,
mut payload: EditorCharacterAnimationBackgroundRemovalRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let options = normalize_editor_character_animation_background_removal(&payload)
.map_err(|error| character_animation_error_response(request_context, error))?;
payload.screen_color = options.screen_color;
let target = preflight_editor_billable_generation_target(
state,
owner_user_id,
@@ -1230,6 +1347,8 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
editor_character_animation_bad_request(error.body_text()),
)
})?;
let options = normalize_editor_character_animation_background_removal(&payload)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let owner_user_id = caller.owner_user_id.clone();
let project_id = payload.project_id.trim().to_string();
let source_layer_id = payload.source_layer_id.trim().to_string();
@@ -1366,6 +1485,15 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
}
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| Some(project_id.clone())),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let persisted_frames = extract_and_prepare_background_removal_frames(
&state,
&caller,
@@ -1373,6 +1501,8 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
&frames,
frame_width,
frame_height,
&options,
&matting_audit,
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
@@ -1400,13 +1530,23 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let generation_inputs = Some(
apply_editor_character_animation_background_removal_metadata(generation_inputs, &options),
);
let completed_at_micros = current_utc_micros();
let prepared = match prepare_editor_character_animation_sequence_result(
&caller,
Some(project_id.as_str()),
payload.asset_folder_id.as_deref(),
asset_label.as_str(),
EDITOR_LOCAL_MATTING_PROVIDER,
match options.mode {
EditorCharacterAnimationBackgroundRemovalMode::SolidColor => {
EDITOR_LOCAL_MATTING_PROVIDER
}
EditorCharacterAnimationBackgroundRemovalMode::General => {
EDITOR_BGFILTER_MATTING_PROVIDER
}
},
Some(source_resource_id.clone()),
generation_inputs,
plan,
@@ -5020,8 +5160,8 @@ async fn extract_and_prepare_direct_video_conversion_frames(
}
}
/// 对已有序列帧逐帧执行纯算法绿幕扣除,生成透明序列帧对象。
/// 输入是源序列帧资源的稳定 objectKey 列表;不调用 BgFilter、阿里云或其它模型。
/// 对已有序列帧按用户显式选择执行本地纯色键控或 BgFilter 通用智能分割,
/// 生成一套新的透明序列帧对象。输入只接受源资源的正式 objectKey 列表;
/// 任一帧失败即整批清理,不发布残缺结果。
async fn extract_and_prepare_background_removal_frames(
state: &AppState,
@@ -5030,6 +5170,8 @@ async fn extract_and_prepare_background_removal_frames(
source_frames: &[EditorCharacterAnimationFramePayload],
frame_width: u32,
frame_height: u32,
options: &NormalizedEditorCharacterAnimationBackgroundRemoval,
matting_audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<PersistedEditorCharacterAnimationFrameSet, AppError> {
use futures_util::StreamExt as _;
@@ -5047,10 +5189,28 @@ async fn extract_and_prepare_background_removal_frames(
"序列帧缺少稳定 objectKey,无法去背景。",
)
})?;
let source_image =
download_editor_persisted_image_object(state, source_object_key).await?;
let removed =
remove_editor_character_animation_green_screen_background(&source_image)?;
let removed = match options.mode {
EditorCharacterAnimationBackgroundRemovalMode::SolidColor => {
let source_image =
download_editor_persisted_image_object(state, source_object_key)
.await?;
remove_editor_character_animation_solid_color_background(
&source_image,
options
.key_rgb
.expect("solid-color options require key RGB"),
)?
}
EditorCharacterAnimationBackgroundRemovalMode::General => {
request_editor_background_removal_image_with_bgfilter_worker(
state,
source_object_key,
matting_audit,
)
.await?
.image
}
};
let finalized = finalize_animation_frame_payload(
removed.bytes.as_slice(),
removed.mime_type.as_str(),
@@ -10309,9 +10469,115 @@ mod tests {
assert!(source.contains("content_type.starts_with(\"image/\")"));
}
fn editor_character_animation_background_removal_request(
mode: EditorCharacterAnimationBackgroundRemovalMode,
screen_color: Option<&str>,
) -> EditorCharacterAnimationBackgroundRemovalRequest {
EditorCharacterAnimationBackgroundRemovalRequest {
project_id: "project-1".to_string(),
source_layer_id: "layer-1".to_string(),
source_resource_id: "resource-1".to_string(),
mode,
screen_color: screen_color.map(str::to_string),
asset_folder_id: None,
asset_label: None,
canvas_completion: None,
generation_inputs: None,
}
}
#[test]
fn editor_character_animation_background_removal_uses_local_green_screen_only() {
fn editor_character_animation_background_removal_requires_consistent_explicit_mode() {
let solid = normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::SolidColor,
Some("#33aaFF"),
),
)
.expect("solid color mode should normalize");
assert_eq!(solid.screen_color.as_deref(), Some("#33AAFF"));
assert_eq!(solid.key_rgb, Some([0x33, 0xAA, 0xFF]));
assert!(
normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::SolidColor,
None,
),
)
.is_err()
);
assert!(
normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::SolidColor,
Some("green"),
),
)
.is_err()
);
assert!(
normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::General,
Some("#00FF00"),
),
)
.is_err()
);
assert!(
normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::General,
None,
),
)
.is_ok()
);
}
#[test]
fn editor_character_animation_background_removal_persists_authoritative_mode_metadata() {
let options = normalize_editor_character_animation_background_removal(
&editor_character_animation_background_removal_request(
EditorCharacterAnimationBackgroundRemovalMode::General,
None,
),
)
.expect("general mode should normalize");
let inputs = apply_editor_character_animation_background_removal_metadata(None, &options);
assert_eq!(inputs["version"], json!(2));
assert_eq!(
inputs["action"],
json!("character-animation.remove-background")
);
assert!(
inputs["fields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| {
field["id"] == "backgroundRemovalMode" && field["value"] == "general"
}))
);
assert!(
inputs["fields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| {
field["id"] == "mattingProvider"
&& field["value"] == EDITOR_BGFILTER_MATTING_PROVIDER
}))
);
}
#[test]
fn editor_character_animation_background_removal_routes_both_modes_before_provider_work() {
let source = include_str!("character_animation_assets.rs");
let enqueue_body = source
.split_once("pub(crate) async fn enqueue_editor_character_animation_background_removal_for_owner")
.and_then(|(_, tail)| tail.split_once("pub(crate) async fn remove_editor_character_animation_background_for_owner"))
.map(|(body, _)| body)
.expect("background removal enqueue function should exist");
assert!(enqueue_body.contains("normalize_editor_character_animation_background_removal"));
let body = source
.split_once("async fn extract_and_prepare_background_removal_frames")
.and_then(|(_, tail)| {
@@ -10320,15 +10586,16 @@ mod tests {
.map(|(body, _)| body)
.expect("background removal frame function should exist");
assert!(body.contains("remove_editor_character_animation_green_screen_background"));
assert!(body.contains("EditorCharacterAnimationBackgroundRemovalMode::SolidColor"));
assert!(body.contains("remove_editor_character_animation_solid_color_background"));
assert!(body.contains("download_editor_persisted_image_object"));
assert!(body.contains("EditorCharacterAnimationBackgroundRemovalMode::General"));
assert!(body.contains("request_editor_background_removal_image_with_bgfilter_worker"));
assert!(body.contains(".buffer_unordered(frame_count.max(1))"));
assert!(body.contains(".collect::<Vec<_>>()"));
assert!(body.contains("processed.sort_by_key"));
assert!(body.contains("frame_errors.sort_by_key"));
assert!(!body.contains("request_editor_background_removal_image_with_bgfilter_worker"));
assert!(!body.contains("remove_editor_generated_screen_background_with_bgfilter"));
assert!(!body.contains("ExternalApiAuditContext"));
}
#[test]
@@ -208,19 +208,24 @@ pub(crate) fn remove_editor_generated_green_screen_background(
})
}
/// 角色动作手动去背景只处理约定的纯绿幕,不调用 BgFilter、阿里云或其它模型。
/// 不启用近白背景清理,避免把白色角色内容误当成背景;低饱和绿色仅作为绿幕边缘
/// 与压缩/抗锯齿造成的颜色变化处理。
pub(crate) fn remove_editor_character_animation_green_screen_background(
/// 角色动作纯色背景模式使用用户显式选择的键色执行本地算法,不调用 BgFilter、
/// 阿里云或其它模型。不额外清理近白背景;只有精确绿幕键色启用低饱和绿边缘修复。
pub(crate) fn remove_editor_character_animation_solid_color_background(
image: &DownloadedOpenAiImage,
key_rgb: [u8; 3],
) -> Result<DownloadedOpenAiImage, AppError> {
let key_color = GeneratedAssetSheetKeyColor {
red: key_rgb[0],
green: key_rgb[1],
blue: key_rgb[2],
};
let bytes = remove_generated_asset_sheet_green_screen_background_bytes(
image.bytes.as_slice(),
GeneratedAssetSheetAlphaOptions {
key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN,
key_color,
remove_near_white_background: false,
remove_disconnected_hard_key_background: true,
remove_muted_green_screen_background: true,
remove_muted_green_screen_background: key_color.is_green_screen(),
detect_internal_holes: true,
internal_hole_min_pixels: 16,
},
@@ -294,13 +299,15 @@ mod tests {
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
.expect("test PNG should encode");
let output =
remove_editor_character_animation_green_screen_background(&DownloadedOpenAiImage {
let output = remove_editor_character_animation_solid_color_background(
&DownloadedOpenAiImage {
bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("green screen should be removed");
},
[0, 255, 0],
)
.expect("green screen should be removed");
let image = image::load_from_memory(output.bytes.as_slice())
.expect("output PNG should decode")
.to_rgba8();
@@ -309,6 +316,32 @@ mod tests {
assert_eq!(image.get_pixel(1, 1).0, [255, 255, 255, 255]);
}
#[test]
fn character_animation_custom_key_color_preserves_green_foreground() {
let mut source = RgbaImage::from_pixel(3, 3, Rgba([0, 0, 255, 255]));
source.put_pixel(1, 1, Rgba([0, 255, 0, 255]));
let mut bytes = Vec::new();
DynamicImage::ImageRgba8(source)
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
.expect("test PNG should encode");
let output = remove_editor_character_animation_solid_color_background(
&DownloadedOpenAiImage {
bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
},
[0, 0, 255],
)
.expect("blue screen should be removed");
let image = image::load_from_memory(output.bytes.as_slice())
.expect("output PNG should decode")
.to_rgba8();
assert_eq!(image.get_pixel(0, 0).0[3], 0);
assert_eq!(image.get_pixel(1, 1).0, [0, 255, 0, 255]);
}
#[test]
fn editor_screen_background_color_parser_uses_default_and_rejects_unknown() {
assert_eq!(
@@ -228,7 +228,7 @@ const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_
pub(crate) const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_BGFILTER_MATTING_PROVIDER: &str = "BgFilter";
pub(crate) const EDITOR_BGFILTER_MATTING_PROVIDER: &str = "BgFilter";
const EDITOR_ALIYUN_MATTING_PROVIDER: &str = "Aliyun Matting";
const EDITOR_ALIYUN_MATTING_MODEL: &str = "segment-common-image";
pub(crate) const EDITOR_LOCAL_MATTING_PROVIDER: &str = "Genarrative Local";
@@ -454,12 +454,22 @@ pub struct EditorCharacterAnimationFrameSplitResponse {
pub assets: Vec<EditorAssetPayload>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum EditorCharacterAnimationBackgroundRemovalMode {
SolidColor,
General,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationBackgroundRemovalRequest {
pub project_id: String,
pub source_layer_id: String,
pub source_resource_id: String,
pub mode: EditorCharacterAnimationBackgroundRemovalMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]