明确序列帧去背景处理模式
去背景弹窗要求用户显式选择纯色键控或通用智能分割。 纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。 通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。 入队与 durable worker 统一校验模式参数并持久化权威处理元数据。 补充双色键控、模式冲突、双路径路由和前端交互回归。 同步更新 Spine 技术方案与团队决策记录。
This commit is contained in:
@@ -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")]
|
||||
|
||||
+67
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user