diff --git a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md index aaa6eb312..759e39a93 100644 --- a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md +++ b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md @@ -602,12 +602,12 @@ flowchart TD #### 去背景 -AI 生成链默认逐帧去背景,视频直接转换固定保留源背景。`character-animation` 浮动工具栏继续提供 `去背景`,与 `改造 / 拆帧 / 下载` 并列;点击后必须走现有 durable 派生任务,不得把序列第一帧当普通图片处理。派生任务保持以下边界: +AI 生成链默认逐帧去背景,视频直接转换固定保留源背景。只有 `character-animation.convert` 的视频直接转换结果提供 `去背景`;AI 生成的序列帧已自动完成去背景,不展示也不接受重复处理。`character-animation` 浮动工具栏在适用时提供 `去背景`,与 `改造 / 拆帧 / 下载` 并列;点击后必须走现有 durable 派生任务,不得把序列第一帧当普通图片处理。派生任务保持以下边界: - 原始不透明序列与透明序列是两个正式 resource; - 去背景全帧成功后一次性发布新的 `character-animation` / `image-sequence` 资源、素材和画布图层,保留原始序列图层不被替换; - 任一帧失败不产生残缺结果; -- v1 去背景仅支持编辑器权威生成且 `backgroundColor=green` 的纯绿幕序列帧;白 / 蓝 / 紫背景、视频直接转换结果、缺失或非法生成配方均由后端拒绝,前端不展示该入口; +- v1 去背景仅支持编辑器权威 `character-animation.convert` 视频直接转换结果;AI 生成的序列帧、已去背景结果、缺失或非法生成配方均由后端拒绝,前端不展示该入口; - 当前费用固定为 0 泥点,失败不产生不完整正式结果;请求先进入现有 `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 eb9d1ea8d..a272c808a 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -1124,24 +1124,14 @@ async fn validate_editor_character_animation_source_frames( Ok(()) } -fn editor_character_animation_sequence_is_green_screen(generation_inputs: Option<&Value>) -> bool { +fn editor_character_animation_sequence_is_video_conversion( + generation_inputs: Option<&Value>, +) -> bool { let Some(inputs) = generation_inputs.and_then(Value::as_object) else { return false; }; - if inputs.get("version").and_then(Value::as_u64) != Some(2) - || inputs.get("action").and_then(Value::as_str) != Some("character-animation.generate") - { - return false; - } - inputs - .get("fields") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|field| { - field.get("id").and_then(Value::as_str) == Some("backgroundColor") - && field.get("value").and_then(Value::as_str) == Some("green") - }) + inputs.get("version").and_then(Value::as_u64) == Some(2) + && inputs.get("action").and_then(Value::as_str) == Some("character-animation.convert") } pub async fn remove_editor_character_animation_background( @@ -1291,13 +1281,13 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner( })), )); } - if !editor_character_animation_sequence_is_green_screen( + if !editor_character_animation_sequence_is_video_conversion( source_resource.generation_inputs.as_ref(), ) { return Err(character_animation_error_response( &request_context, editor_character_animation_bad_request( - "当前仅支持由编辑器生成且背景为纯绿幕的角色动作序列帧去背景。", + "AI生成的角色动作序列帧已自动去背景,只有视频直接转换结果可以再次去背景。", ), )); } @@ -9342,41 +9332,40 @@ mod tests { } #[test] - fn editor_character_animation_background_removal_requires_green_generated_inputs() { - let green_inputs = json!({ + fn editor_character_animation_background_removal_requires_video_conversion_inputs() { + let converted_inputs = json!({ "version": 2, - "action": "character-animation.generate", - "fields": [{ - "id": "backgroundColor", - "value": "green" - }] + "action": "character-animation.convert", + "fields": [] }); - assert!(editor_character_animation_sequence_is_green_screen(Some( - &green_inputs - ))); + assert!(editor_character_animation_sequence_is_video_conversion( + Some(&converted_inputs) + )); for inputs in [ json!({ "version": 2, "action": "character-animation.generate", - "fields": [{ "id": "backgroundColor", "value": "white" }] - }), - json!({ - "version": 2, - "action": "character-animation.generate", - "fields": [{ "id": "backgroundColor", "value": "blue" }] - }), - json!({ - "version": 2, - "action": "character-animation.convert", "fields": [{ "id": "backgroundColor", "value": "green" }] }), + json!({ + "version": 2, + "action": "character-animation.remove-background", + "fields": [] + }), + json!({ + "version": 1, + "action": "character-animation.convert", + "fields": [] + }), ] { - assert!(!editor_character_animation_sequence_is_green_screen(Some( - &inputs - ))); + assert!(!editor_character_animation_sequence_is_video_conversion( + Some(&inputs) + )); } - assert!(!editor_character_animation_sequence_is_green_screen(None)); + assert!(!editor_character_animation_sequence_is_video_conversion( + None + )); } #[test] diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 0371f104d..c87c194b3 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -70,15 +70,13 @@ import { } from './ImageCanvasGenerationModel'; describe('ImageCanvasGenerationModel', () => { - it('allows sequence background removal only for green generated animations', () => { + it('allows sequence background removal only for direct video conversions', () => { const layer = { assetKind: 'character-animation', generationInputs: { version: 2 as const, - action: 'character-animation.generate' as const, - fields: [ - { id: 'backgroundColor', title: '背景颜色', value: 'green' as const }, - ], + action: 'character-animation.convert' as const, + fields: [], references: [], }, } as unknown as CanvasLayer; @@ -88,7 +86,7 @@ describe('ImageCanvasGenerationModel', () => { ...layer, generationInputs: { ...layer.generationInputs!, - action: 'character-animation.convert', + action: 'character-animation.generate', }, }), ).toBe(false); @@ -97,7 +95,7 @@ describe('ImageCanvasGenerationModel', () => { ...layer, generationInputs: { ...layer.generationInputs!, - fields: [{ id: 'backgroundColor', title: '背景颜色', value: 'blue' }], + action: 'character-animation.remove-background', }, }), ).toBe(false); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index c92fa81b0..9b04ac170 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1041,15 +1041,10 @@ export function buildCharacterAnimationGenerationInputs( } export function canRemoveCharacterAnimationBackground(layer: CanvasLayer) { - if ( - layer.assetKind !== 'character-animation' || - layer.generationInputs?.version !== 2 || - layer.generationInputs.action !== 'character-animation.generate' - ) { - return false; - } - return layer.generationInputs.fields.some( - (field) => field.id === 'backgroundColor' && field.value === 'green', + return ( + layer.assetKind === 'character-animation' && + layer.generationInputs?.version === 2 && + layer.generationInputs.action === 'character-animation.convert' ); } diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index d86ec8187..bae02221a 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -459,11 +459,8 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { assetKind: 'character-animation', generationInputs: { version: 2, - action: 'character-animation.generate', - fields: [ - { id: 'prompt', title: '动作描述', value: '挥手' }, - { id: 'backgroundColor', title: '背景颜色', value: 'green' }, - ], + action: 'character-animation.convert', + fields: [], references: [], }, }), @@ -492,21 +489,18 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { ); }); - it('hides sequence background removal unless the authoritative background is green', () => { - const whiteLayer = createLayer({ + it('shows sequence background removal only for direct video conversions', () => { + const generatedLayer = createLayer({ mediaType: 'image-sequence', assetKind: 'character-animation', generationInputs: { version: 2, action: 'character-animation.generate', - fields: [ - { id: 'prompt', title: '动作描述', value: '挥手' }, - { id: 'backgroundColor', title: '背景颜色', value: 'white' }, - ], + fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], references: [], }, }); - renderSelectedToolbar({ selectedLayer: whiteLayer }); + renderSelectedToolbar({ selectedLayer: generatedLayer }); expect(screen.queryByRole('button', { name: '去背景' })).toBeNull(); @@ -517,13 +511,13 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { generationInputs: { version: 2, action: 'character-animation.convert', - fields: [{ id: 'backgroundColor', title: '背景颜色', value: 'green' }], + fields: [], references: [], }, }); renderSelectedToolbar({ selectedLayer: convertedLayer }); - expect(screen.queryByRole('button', { name: '去背景' })).toBeNull(); + expect(screen.getByRole('button', { name: '去背景' })).toBeTruthy(); }); it('disables frame splitting while a character animation resource is pending registration', () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 234679f1c..24b0ae332 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -3067,6 +3067,12 @@ describe('useImageCanvasGenerationWorkflow', () => { title: '勇者奔跑', assetKind: 'character-animation', mediaType: 'image-sequence', + generationInputs: { + version: 2, + action: 'character-animation.convert', + fields: [], + references: [], + }, }), ]} />, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index db2b7d611..d673e8292 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -96,6 +96,7 @@ import { appendLimitedImageReferences, buildDeterministicGenerationInputs, calculateCharacterAnimationPrice, + canRemoveCharacterAnimationBackground, canSplitCharacterAnimationFrames, CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, CHARACTER_ANIMATION_DURATION_OPTIONS, @@ -3677,6 +3678,7 @@ export function useImageCanvasGenerationWorkflow({ const removeSelectedCharacterAnimationBackground = useCallback( async (sourceLayer: CanvasLayer) => { if ( + !canRemoveCharacterAnimationBackground(sourceLayer) || !canSplitCharacterAnimationFrames(sourceLayer) || !projectId || removingBackgroundCharacterAnimationLayerIdsRef.current.has(