From 3d145379f2c8d4d1fb6f042a712dbc007d0de727 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 17 Aug 2026 15:27:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=BA=8F=E5=88=97=E5=B8=A7?= =?UTF-8?q?=E5=8E=BB=E8=83=8C=E6=99=AF=E5=A4=84=E7=90=86=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去背景弹窗要求用户显式选择纯色键控或通用智能分割。 纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。 通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。 入队与 durable worker 统一校验模式参数并持久化权威处理元数据。 补充双色键控、模式冲突、双路径路由和前端交互回归。 同步更新 Spine 技术方案与团队决策记录。 --- .../shared-memory/decision-log.md | 7 + ...ArtAgent多模态Spine序列帧动画生成-2026-08-10.md | 10 +- .../src/character_animation_assets.rs | 297 +++++++++++++++++- .../api-server/src/editor_green_screen.rs | 53 +++- .../crates/api-server/src/editor_project.rs | 2 +- .../crates/shared-contracts/src/assets.rs | 10 + ...rAnimationBackgroundRemovalDialog.test.tsx | 67 ++++ ...racterAnimationBackgroundRemovalDialog.tsx | 137 ++++++++ .../image-editor/ImageCanvasEditorView.tsx | 54 +++- .../useImageCanvasGenerationWorkflow.test.tsx | 7 +- .../useImageCanvasGenerationWorkflow.ts | 13 +- .../image-editor/editorProjectClient.test.ts | 31 ++ .../image-editor/editorProjectClient.ts | 10 + 13 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.test.tsx create mode 100644 src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.tsx diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 872c1f78e..ec6a14157 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 门禁。 diff --git a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md index 76134a7f9..d42b6f366 100644 --- a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md +++ b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md @@ -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` 与历史结果发生幂等冲突。 #### 拆帧 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 07744c438..1dcc67219 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -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, + key_rgb: Option<[u8; 3]>, +} + +fn normalize_editor_character_animation_background_removal( + payload: &EditorCharacterAnimationBackgroundRemovalRequest, +) -> Result { + 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, + 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, Extension(request_context): Extension, @@ -1186,6 +1300,9 @@ pub(crate) async fn enqueue_editor_character_animation_background_removal_for_ow owner_user_id: &str, mut payload: EditorCharacterAnimationBackgroundRemovalRequest, ) -> Result { + 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 { 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::>()")); 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] diff --git a/server-rs/crates/api-server/src/editor_green_screen.rs b/server-rs/crates/api-server/src/editor_green_screen.rs index cc1c1a40c..c7ab30e53 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -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 { + 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!( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e7e856857..a03b3bf45 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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"; diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 2ee907195..350d1399e 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -454,12 +454,22 @@ pub struct EditorCharacterAnimationFrameSplitResponse { pub assets: Vec, } +#[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, #[serde(default, skip_serializing_if = "Option::is_none")] pub asset_folder_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.test.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.test.tsx new file mode 100644 index 000000000..82346227a --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.test.tsx @@ -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('solid-color'); + const [screenColor, setScreenColor] = useState('#00FF00'); + + return ( + + ); +} + +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(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: '吸取画布颜色' })); + + await waitFor(() => + expect( + (screen.getByLabelText('纯色背景颜色') as HTMLInputElement).value, + ).toBe('#336699'), + ); + expect(open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.tsx new file mode 100644 index 000000000..beee18158 --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterAnimationBackgroundRemovalDialog.tsx @@ -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 ( + +
+ + +
+ + {mode === 'solid-color' ? ( +
+ + {screenColor} + void pickScreenColor()} + > + {isPickingColor ? '取色中' : '吸取画布颜色'} + +
+ ) : null} +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index aebb5fc3b..f4833ce5e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); + const [ + characterAnimationBackgroundRemovalDraft, + setCharacterAnimationBackgroundRemovalDraft, + ] = useState(null); const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] = useState(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), }} /> + + 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)); + }} + /> - 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', }), ); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index d673e8292..f781ee7de 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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 diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 37c0cd1fc..ad7f2ed40 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -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: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e59418f3c..963b98525 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -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