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

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

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

通用模式逐帧使用 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
@@ -14185,3 +14185,10 @@
- 背景:视频模式的 `appearanceReferenceSrcs` 原先会在 Provider 调用前逐项下载并完整解码,但该实际执行路径没有复用通用视频参考的 9 张数量上限,也没有单图、累计字节、解码边长和像素预算,可能让第 10 张或压缩炸弹图片持续占用 durable worker 资源。
- 决策:角色外观图最多 9 张,单图最大 30MB、合计最大 64MB,编码边长最大 8192、单图总像素最大 33554432。前端上传追加与草稿恢复收口到 9 张;HTTP 入队和 worker 共用请求归一化数量门禁;worker 在 Provider 调用前按 OSS HEAD、流式下载字节上限、解码器资源限制及显式像素计算失败关闭。既有 owner、稳定 objectKey、MIME 和来源资源校验继续保留。
- 验证:覆盖 9/10 张边界、30MB 单图字节边界、8192 边长及总像素边界,并运行角色动作 Rust 定向测试、相关前端 Vitest、类型检查、编码与 diff 门禁。
## 2026-08-17 Spine 视频转换去背景改为显式双模式
- 背景:仅凭 `generationInputs.action = character-animation.convert` 无法证明源视频是绿幕;把任意转换结果固定按 `#00FF00` 键控会误处理普通背景或绿色主体。仓库已有 BgFilter `complex + birefnet` 通用分割服务,也已有可传 `screen_color` 的 flat 协议,但纯色场景不需要承担外部服务成本。
- 决策:只有视频直接转换序列继续显示“去背景”,点击后必须在独立弹窗显式选择 `solid-color``general`。纯色模式默认 `#00FF00`,允许颜色输入及浏览器吸管,后端使用参数化本地键色算法;通用模式逐帧调用 BgFilter complex,不携带键色。HTTP 入队和 durable worker 共用白名单校验,后端在结果 `generationInputs` 中权威记录模式、颜色或 Provider/model,不能再把“视频转换”本身当作绿幕证明。两种模式仍生成新序列资源、整批失败关闭并保留源序列。
- 费用边界:本次沿用现有手动 BgFilter 的 0 泥点内部契约;供应商成本不等于已确定的用户泥点售价,在没有正式定价决策时不自行新增价格。若后续收费,必须接入统一后端定价、入队冻结价格和 worker 扣费退款链路。
- 验证:覆盖缺失/非法纯色、通用模式禁止颜色、任意键色透传、绿色主体在非绿色键色下保留、双模式 worker 路由、前端模式选择及吸管回填,并运行角色动作 Rust 定向测试、前端 Vitest、类型检查、编码和 diff 门禁。
@@ -603,14 +603,16 @@ flowchart TD
#### 去背景
AI 生成链默认逐帧去背景,视频直接转换固定保留源背景。只有 `character-animation.convert` 的视频直接转换结果提供 `去背景`;AI 生成的序列帧已自动完成去背景,不展示也不接受重复处理。`character-animation` 浮动工具栏在适用时提供 `去背景`,与 `改造 / 拆帧 / 下载` 并列;点击后必须走现有 durable 派生任务,不得把序列第一帧当普通图片处理。派生任务保持以下边界:
AI 生成链默认逐帧去背景,视频直接转换固定保留源背景。只有 `character-animation.convert` 的视频直接转换结果提供 `去背景`;AI 生成的序列帧已自动完成去背景,不展示也不接受重复处理。`character-animation` 浮动工具栏在适用时提供 `去背景`,与 `改造 / 拆帧 / 下载` 并列;点击后弹出独立选择框,用户必须显式选择处理方式,再进入现有 durable 派生任务,不得把序列第一帧当普通图片处理。派生任务保持以下边界:
- `solid-color` 为本地纯色键控,默认 `#00FF00`,允许颜色输入和浏览器吸管选择任意 `#RRGGBB`;该模式不调用 BgFilter、阿里云或其它外部模型。
- `general` 为通用智能分割,不携带 `screenColor`,逐帧调用 BgFilter `complex + birefnet`;普通背景不再被隐式当作绿幕处理。当前内部接口沿用现有手动 BgFilter 的 0 泥点契约,不在缺少正式定价决策时自行发明用户收费金额;外部供应商成本与后续用户定价另行配置。
- 前端只负责显式选择;HTTP 入队与 durable worker 共用白名单校验:纯色模式必须携带合法颜色,通用模式禁止携带颜色。worker 只读取已持久化任务参数,最终资源的 `generationInputs` 由后端写入模式、键色或 Provider/model,不信任客户端自报配方。
- 原始不透明序列与透明序列是两个正式 resource;
- 去背景全帧成功后一次性发布新的 `character-animation` / `image-sequence` 资源、素材和画布图层,保留原始序列图层不被替换;
- 任一帧失败不产生残缺结果;
- v1 去背景仅支持编辑器权威 `character-animation.convert` 视频直接转换结果;AI 生成的序列帧、已去背景结果、缺失或非法生成配方均由后端拒绝,前端不展示该入口;
- 当前费用固定为 0 泥点,失败不产生不完整正式结果;请求先进入现有 `external_generation_job` 队列,不能由 HTTP 请求同步执行。
- 队列 worker 保持一个父 job,在 job 内按帧并行执行下载、纯本地绿幕扣除、最终 PNG 上传和帧对象准备;所有已发出的帧必须 drain 完成后再按帧号排序,一帧失败即清理未提交对象并整批失败,不新增逐帧子任务表或独立计费。
- 去背景仅支持编辑器权威 `character-animation.convert` 视频直接转换结果;AI 生成的序列帧、已去背景结果、缺失或非法生成配方均由后端拒绝,前端不展示该入口;
- 请求先进入现有 `external_generation_job` 队列,不能由 HTTP 请求同步执行。队列 worker 保持一个父 job,在 job 内按帧并行执行处理、最终 PNG 上传和帧对象准备;所有已发出的帧必须 drain 完成后再按帧号排序,一帧失败即清理未提交对象并整批失败,不新增逐帧子任务表。
- 帧对象路径绑定本次 job 的 `operationId`,同一 job 重放复用路径,不同 job 即使请求 fingerprint 相同也不得复用旧 object location,避免新 `asset_object` 与历史结果发生幂等冲突。
#### 拆帧
@@ -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")]
@@ -0,0 +1,67 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EditorCharacterAnimationBackgroundRemovalMode } from '../../services/image-editor/editorProjectClient';
import { ImageCanvasCharacterAnimationBackgroundRemovalDialog } from './ImageCanvasCharacterAnimationBackgroundRemovalDialog';
function DialogHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void }) {
const [mode, setMode] =
useState<EditorCharacterAnimationBackgroundRemovalMode>('solid-color');
const [screenColor, setScreenColor] = useState('#00FF00');
return (
<ImageCanvasCharacterAnimationBackgroundRemovalDialog
open
mode={mode}
screenColor={screenColor}
onModeChange={setMode}
onScreenColorChange={setScreenColor}
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
}
afterEach(() => {
delete (window as typeof window & { EyeDropper?: unknown }).EyeDropper;
});
describe('ImageCanvasCharacterAnimationBackgroundRemovalDialog', () => {
it('requires an explicit pure-color or general mode selection', () => {
const onConfirm = vi.fn();
render(<DialogHarness onConfirm={onConfirm} />);
expect(
(screen.getByRole('radio', { name: /纯色背景抠图/ }) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText('纯色背景颜色') as HTMLInputElement).value,
).toBe('#00ff00');
fireEvent.click(screen.getByRole('radio', { name: /通用智能抠图/ }));
expect(screen.queryByLabelText('纯色背景颜色')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '开始处理' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('uses the browser eyedropper result as the solid background color', async () => {
const open = vi.fn().mockResolvedValue({ sRGBHex: '#336699' });
(window as typeof window & { EyeDropper?: unknown }).EyeDropper = class {
open = open;
};
render(<DialogHarness />);
fireEvent.click(screen.getByRole('button', { name: '吸取画布颜色' }));
await waitFor(() =>
expect(
(screen.getByLabelText('纯色背景颜色') as HTMLInputElement).value,
).toBe('#336699'),
);
expect(open).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,137 @@
import { useCallback, useState } from 'react';
import type { EditorCharacterAnimationBackgroundRemovalMode } from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
type EyeDropperConstructor = new () => {
open: () => Promise<{ sRGBHex: string }>;
};
type ImageCanvasCharacterAnimationBackgroundRemovalDialogProps = {
open: boolean;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor: string;
busy?: boolean;
onModeChange: (mode: EditorCharacterAnimationBackgroundRemovalMode) => void;
onScreenColorChange: (screenColor: string) => void;
onClose: () => void;
onConfirm: () => void;
};
function resolveEyeDropper() {
return (window as typeof window & { EyeDropper?: EyeDropperConstructor })
.EyeDropper;
}
export function ImageCanvasCharacterAnimationBackgroundRemovalDialog({
open,
mode,
screenColor,
busy = false,
onModeChange,
onScreenColorChange,
onClose,
onConfirm,
}: ImageCanvasCharacterAnimationBackgroundRemovalDialogProps) {
const [isPickingColor, setIsPickingColor] = useState(false);
const pickScreenColor = useCallback(async () => {
const EyeDropper = resolveEyeDropper();
if (!EyeDropper || isPickingColor) {
return;
}
setIsPickingColor(true);
try {
const result = await new EyeDropper().open();
if (/^#[0-9a-f]{6}$/i.test(result.sRGBHex)) {
onScreenColorChange(result.sRGBHex.toUpperCase());
}
} catch {
// 用户取消系统吸管不属于错误,保持当前颜色即可。
} finally {
setIsPickingColor(false);
}
}, [isPickingColor, onScreenColorChange]);
return (
<UnifiedConfirmDialog
open={open}
title="序列帧去背景"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="开始处理"
busy={busy}
busyConfirmLabel="提交中"
confirmDisabled={
mode === 'solid-color' && !/^#[0-9A-F]{6}$/i.test(screenColor)
}
showCancel
size="md"
panelClassName="platform-remap-surface"
>
<fieldset className="grid gap-3" aria-label="去背景方式">
<label className="flex cursor-pointer gap-3 rounded-xl border border-[var(--platform-border-soft)] p-3">
<input
type="radio"
name="character-animation-background-removal-mode"
value="solid-color"
checked={mode === 'solid-color'}
disabled={busy}
onChange={() => onModeChange('solid-color')}
/>
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-[var(--platform-text-muted)]">
使
</span>
</span>
</label>
<label className="flex cursor-pointer gap-3 rounded-xl border border-[var(--platform-border-soft)] p-3">
<input
type="radio"
name="character-animation-background-removal-mode"
value="general"
checked={mode === 'general'}
disabled={busy}
onChange={() => onModeChange('general')}
/>
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-[var(--platform-text-muted)]">
</span>
</span>
</label>
</fieldset>
{mode === 'solid-color' ? (
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="color"
aria-label="纯色背景颜色"
value={screenColor}
disabled={busy}
onChange={(event) =>
onScreenColorChange(event.currentTarget.value.toUpperCase())
}
className="h-9 w-12 cursor-pointer rounded border border-[var(--platform-border-soft)] bg-transparent p-0.5"
/>
</label>
<code className="text-xs">{screenColor}</code>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
disabled={busy || isPickingColor || !resolveEyeDropper()}
onClick={() => void pickScreenColor()}
>
{isPickingColor ? '取色中' : '吸取画布颜色'}
</PlatformActionButton>
</div>
) : null}
</UnifiedConfirmDialog>
);
}
@@ -18,6 +18,7 @@ import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorCharacterAnimationBackgroundRemovalMode,
type EditorProjectSnapshot,
loadEditorGenerationPricing,
loadEditorProject,
@@ -34,6 +35,7 @@ import {
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import type { ImageCanvasActionResult } from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import { ImageCanvasCharacterAnimationBackgroundRemovalDialog } from './ImageCanvasCharacterAnimationBackgroundRemovalDialog';
import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
@@ -209,6 +211,12 @@ function openCanvasStartupTool(
generationSurface.openGenerateDialog();
}
type CharacterAnimationBackgroundRemovalDraft = {
sourceLayer: CanvasLayer;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor: string;
};
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
@@ -507,6 +515,10 @@ export function ImageCanvasEditorView({
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [
characterAnimationBackgroundRemovalDraft,
setCharacterAnimationBackgroundRemovalDraft,
] = useState<CharacterAnimationBackgroundRemovalDraft | null>(null);
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
useState<CanvasGenerationDialogState | null>(null);
const [imageContextMenu, setImageContextMenu] =
@@ -2673,7 +2685,11 @@ export function ImageCanvasEditorView({
},
onRemoveCharacterAnimationBackground: (layer: CanvasLayer) => {
void flushProjectPersistence().then(() =>
removeSelectedCharacterAnimationBackground(layer),
setCharacterAnimationBackgroundRemovalDraft({
sourceLayer: layer,
mode: 'solid-color',
screenColor: '#00FF00',
}),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
@@ -2744,6 +2760,42 @@ export function ImageCanvasEditorView({
onClose: () => setMetadataLayer(null),
}}
/>
<ImageCanvasCharacterAnimationBackgroundRemovalDialog
open={Boolean(characterAnimationBackgroundRemovalDraft)}
mode={characterAnimationBackgroundRemovalDraft?.mode ?? 'solid-color'}
screenColor={
characterAnimationBackgroundRemovalDraft?.screenColor ?? '#00FF00'
}
busy={Boolean(
characterAnimationBackgroundRemovalDraft &&
removingBackgroundCharacterAnimationLayerIds.has(
characterAnimationBackgroundRemovalDraft.sourceLayer.id,
),
)}
onModeChange={(mode) =>
setCharacterAnimationBackgroundRemovalDraft((draft) =>
draft ? { ...draft, mode } : draft,
)
}
onScreenColorChange={(screenColor) =>
setCharacterAnimationBackgroundRemovalDraft((draft) =>
draft ? { ...draft, screenColor } : draft,
)
}
onClose={() => setCharacterAnimationBackgroundRemovalDraft(null)}
onConfirm={() => {
const draft = characterAnimationBackgroundRemovalDraft;
if (!draft) {
return;
}
void removeSelectedCharacterAnimationBackground(draft.sourceLayer, {
mode: draft.mode,
...(draft.mode === 'solid-color'
? { screenColor: draft.screenColor }
: {}),
}).then(() => setCharacterAnimationBackgroundRemovalDraft(null));
}}
/>
<PlatformDangerConfirmDialog
open={Boolean(pendingGenerationDeleteDialog)}
title="删除生成中的占位图"
@@ -947,7 +947,10 @@ function GenerationWorkflowHarness({
<button
type="button"
onClick={() =>
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!)
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!, {
mode: 'solid-color',
screenColor: '#00FF00',
})
}
>
@@ -3088,6 +3091,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: 'project-1',
sourceLayerId: 'layer-source',
sourceResourceId: 'resource-source',
mode: 'solid-color',
screenColor: '#00FF00',
}),
);
@@ -19,6 +19,7 @@ import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMe
import {
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorCharacterAnimationBackgroundRemovalMode,
type EditorPixelArtSnapInput,
type EditorPixelArtSnapResult,
type EditorProjectLayerSnapshot,
@@ -3676,7 +3677,13 @@ export function useImageCanvasGenerationWorkflow({
);
const removeSelectedCharacterAnimationBackground = useCallback(
async (sourceLayer: CanvasLayer) => {
async (
sourceLayer: CanvasLayer,
options: {
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor?: string;
},
) => {
if (
!canRemoveCharacterAnimationBackground(sourceLayer) ||
!canSplitCharacterAnimationFrames(sourceLayer) ||
@@ -3716,6 +3723,10 @@ export function useImageCanvasGenerationWorkflow({
projectId,
sourceLayerId: sourceLayer.id,
sourceResourceId: sourceLayer.resourceId,
mode: options.mode,
...(options.mode === 'solid-color' && options.screenColor
? { screenColor: options.screenColor }
: {}),
assetFolderId,
assetLabel,
...(backgroundRemovalPlacement?.placeholder
@@ -36,6 +36,7 @@ import {
optimizeEditorSoundEffectPrompt,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
removeEditorCharacterAnimationBackground,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
@@ -2552,6 +2553,36 @@ describe('editorProjectClient', () => {
);
});
it('sends explicit character animation background removal modes and only sends color for solid mode', async () => {
requestJsonMock.mockResolvedValue({
queueState: { operationId: 'character-removal-1', status: 'queued' },
});
await removeEditorCharacterAnimationBackground({
projectId: 'editor-project-1',
sourceLayerId: 'layer-sequence-1',
sourceResourceId: 'resource-sequence-1',
mode: 'solid-color',
screenColor: '#336699',
});
await removeEditorCharacterAnimationBackground({
projectId: 'editor-project-1',
sourceLayerId: 'layer-sequence-1',
sourceResourceId: 'resource-sequence-1',
mode: 'general',
screenColor: '#00FF00',
});
const solidBody = JSON.parse(requestJsonMock.mock.calls[0]?.[1]?.body);
const generalBody = JSON.parse(requestJsonMock.mock.calls[1]?.[1]?.body);
expect(solidBody).toMatchObject({
mode: 'solid-color',
screenColor: '#336699',
});
expect(generalBody).toMatchObject({ mode: 'general' });
expect(generalBody).not.toHaveProperty('screenColor');
});
it('passes canvas completion context to background removal', async () => {
requestJsonMock.mockResolvedValueOnce({
queueState: {
@@ -634,10 +634,16 @@ export type EditorImageSequenceFrameResult = {
height: number;
};
export type EditorCharacterAnimationBackgroundRemovalMode =
| 'solid-color'
| 'general';
export type EditorCharacterAnimationBackgroundRemovalInput = {
projectId: string;
sourceLayerId: string;
sourceResourceId: string;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor?: string | null;
assetFolderId?: string | null;
assetLabel?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
@@ -1672,6 +1678,10 @@ export async function removeEditorCharacterAnimationBackground(
projectId: input.projectId,
sourceLayerId: input.sourceLayerId,
sourceResourceId: input.sourceResourceId,
mode: input.mode,
...(input.mode === 'solid-color' && input.screenColor
? { screenColor: input.screenColor }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.canvasCompletion