From 101fd952d5ab0792220ace59db33e6d040edd1e1 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 15 Aug 2026 14:50:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=BA=8F=E5=88=97=E5=B8=A7?= =?UTF-8?q?=E5=8E=BB=E8=83=8C=E6=99=AF=E6=9D=A5=E6=BA=90=E4=B8=8E=E7=BB=BF?= =?UTF-8?q?=E5=B9=95=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证去背景每帧正式资产对象的用户归属、对象路径和来源任务 仅允许编辑器生成的纯绿幕角色动画执行算法去背景 补齐去背景生成 action 白名单但保持不可改造 更新工具栏门禁、回归测试和Spine序列帧技术方案 --- ...ArtAgent多模态Spine序列帧动画生成-2026-08-10.md | 1 + .../src/character_animation_assets.rs | 186 ++++++++++++++++++ .../ImageCanvasGenerationInputsModel.ts | 1 + .../ImageCanvasGenerationModel.test.ts | 61 ++++++ .../ImageCanvasGenerationModel.ts | 13 ++ ...ageCanvasSelectedLayerToolbarView.test.tsx | 39 +++- .../ImageCanvasSelectedLayerToolbarView.tsx | 65 +++--- 7 files changed, 336 insertions(+), 30 deletions(-) diff --git a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md index f27d3195d..aaa6eb312 100644 --- a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md +++ b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md @@ -607,6 +607,7 @@ AI 生成链默认逐帧去背景,视频直接转换固定保留源背景。`c - 原始不透明序列与透明序列是两个正式 resource; - 去背景全帧成功后一次性发布新的 `character-animation` / `image-sequence` 资源、素材和画布图层,保留原始序列图层不被替换; - 任一帧失败不产生残缺结果; +- v1 去背景仅支持编辑器权威生成且 `backgroundColor=green` 的纯绿幕序列帧;白 / 蓝 / 紫背景、视频直接转换结果、缺失或非法生成配方均由后端拒绝,前端不展示该入口; - 当前费用固定为 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 d9d018ea0..eb9d1ea8d 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -1049,6 +1049,101 @@ pub async fn split_editor_character_animation_frames( )) } +/// 在读取源序列帧前验证每一帧的正式资产对象归属和来源。 +/// +/// `imageSequenceFrames` 属于可持久化项目数据,不能单独作为 OSS 读取授权; +/// 必须同时证明 assetObjectId、objectKey、账号和来源任务彼此一致。 +async fn validate_editor_character_animation_source_frames( + state: &AppState, + owner_user_id: &str, + source_task_id: &str, + frames: &[EditorCharacterAnimationFramePayload], + provider: &str, +) -> Result<(), AppError> { + for (index, frame) in frames.iter().enumerate() { + let frame_number = index + 1; + let asset_object_id = frame + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": provider, + "message": format!("第{frame_number}帧缺少assetObjectId。"), + })) + })?; + let object_key = frame + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": provider, + "message": format!("第{frame_number}帧缺少objectKey。"), + })) + })?; + let asset_object = state + .spacetime_client() + .get_asset_object(asset_object_id.to_string()) + .await + .map_err(map_editor_project_error)? + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": provider, + "message": format!("第{frame_number}帧的正式资产对象不存在。"), + })) + })?; + if asset_object.owner_user_id.as_deref() != Some(owner_user_id) + || asset_object.object_key != object_key + || asset_object.source_job_id.as_deref() != Some(source_task_id) + { + return Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": provider, + "message": format!("第{frame_number}帧的资产对象归属或来源不一致。"), + })), + ); + } + if !asset_object + .content_type + .as_deref() + .is_some_and(|content_type| content_type.starts_with("image/")) + || frame.width == 0 + || frame.height == 0 + { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": provider, + "message": format!("第{frame_number}帧不是有效的图片资产。"), + })), + ); + } + } + Ok(()) +} + +fn editor_character_animation_sequence_is_green_screen(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") + }) +} + pub async fn remove_editor_character_animation_background( State(state): State, Extension(request_context): Extension, @@ -1196,6 +1291,16 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner( })), )); } + if !editor_character_animation_sequence_is_green_screen( + source_resource.generation_inputs.as_ref(), + ) { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request( + "当前仅支持由编辑器生成且背景为纯绿幕的角色动作序列帧去背景。", + ), + )); + } let frames: Vec = source_resource .image_sequence_frames .clone() @@ -1215,6 +1320,26 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner( )), )); } + let source_task_id = source_resource + .task_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + character_animation_error_response( + &request_context, + editor_character_animation_bad_request("序列帧资源缺少可信来源任务。"), + ) + })?; + validate_editor_character_animation_source_frames( + &state, + owner_user_id.as_str(), + source_task_id.as_str(), + &frames, + "editor-character-animation-background-removal", + ) + .await + .map_err(|error| character_animation_error_response(&request_context, error))?; let frame_width = source_resource.width; let frame_height = source_resource.height; let duration_ms = source_resource.image_sequence_duration_ms.unwrap_or(0); @@ -9216,6 +9341,44 @@ mod tests { assert!(!lower.contains("birefnet")); } + #[test] + fn editor_character_animation_background_removal_requires_green_generated_inputs() { + let green_inputs = json!({ + "version": 2, + "action": "character-animation.generate", + "fields": [{ + "id": "backgroundColor", + "value": "green" + }] + }); + assert!(editor_character_animation_sequence_is_green_screen(Some( + &green_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" }] + }), + ] { + assert!(!editor_character_animation_sequence_is_green_screen(Some( + &inputs + ))); + } + assert!(!editor_character_animation_sequence_is_green_screen(None)); + } + #[test] fn parse_video_data_url_accepts_mp4_payload() { let parsed = @@ -9991,6 +10154,29 @@ mod tests { assert!(first.ends_with("-task-1")); } + #[test] + fn editor_character_animation_background_removal_validates_each_source_frame() { + let source = include_str!("character_animation_assets.rs"); + let body = source + .split_once( + "pub(crate) async fn remove_editor_character_animation_background_for_owner", + ) + .and_then(|(_, tail)| { + tail.split_once("async fn extract_and_prepare_background_removal_frames") + }) + .map(|(body, _)| body) + .expect("background removal handler should exist"); + + assert!(body.contains("validate_editor_character_animation_source_frames")); + assert!(body.contains("let source_task_id = source_resource")); + assert!(body.contains(".task_id")); + assert!(body.contains("source_resource.generation_inputs")); + assert!(source.contains("asset_object.owner_user_id.as_deref()")); + assert!(source.contains("asset_object.object_key != object_key")); + assert!(source.contains("asset_object.source_job_id.as_deref()")); + assert!(source.contains("content_type.starts_with(\"image/\")")); + } + #[test] fn editor_character_animation_background_removal_uses_local_green_screen_only() { let source = include_str!("character_animation_assets.rs"); diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index e702dd991..ec79a2b7e 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -26,6 +26,7 @@ export const CANVAS_GENERATION_ACTIONS = [ 'audio.background-music.generate', 'character-animation.generate', 'character-animation.convert', + 'character-animation.remove-background', 'image.edit', 'ui-design.extract-assets', 'image.perfect-pixel', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c77ebf177..2d421edd0 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -5,6 +5,10 @@ import type { CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; +import { + hydrateCanvasGenerationInputsResult, + isRemixableCanvasGenerationAction, +} from './ImageCanvasGenerationInputsModel'; import { applyEditorGenerationPricingConfig, BACKGROUND_MUSIC_MODEL_SUNO, @@ -31,6 +35,7 @@ import { calculateEditorUiDesignPrice, calculateEditorVideoPrice, canOpenRedrawPanel, + canRemoveCharacterAnimationBackground, CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, CHARACTER_ANIMATION_MODEL, createCanvasLayerReference, @@ -65,6 +70,62 @@ import { } from './ImageCanvasGenerationModel'; describe('ImageCanvasGenerationModel', () => { + it('allows sequence background removal only for green generated animations', () => { + const layer = { + assetKind: 'character-animation', + generationInputs: { + version: 2 as const, + action: 'character-animation.generate' as const, + fields: [ + { id: 'backgroundColor', title: '背景颜色', value: 'green' as const }, + ], + references: [], + }, + } as unknown as CanvasLayer; + expect(canRemoveCharacterAnimationBackground(layer)).toBe(true); + expect( + canRemoveCharacterAnimationBackground({ + ...layer, + generationInputs: { + ...layer.generationInputs!, + action: 'character-animation.convert', + }, + }), + ).toBe(false); + expect( + canRemoveCharacterAnimationBackground({ + ...layer, + generationInputs: { + ...layer.generationInputs!, + fields: [{ id: 'backgroundColor', title: '背景颜色', value: 'blue' }], + }, + }), + ).toBe(false); + expect( + canRemoveCharacterAnimationBackground({ + ...layer, + generationInputs: undefined, + }), + ).toBe(false); + }); + + it('hydrates remove-background as a known but non-remixable action', () => { + const result = hydrateCanvasGenerationInputsResult({ + version: 2, + action: 'character-animation.remove-background', + fields: [], + references: [], + }); + + expect(result.state).toBe('valid'); + expect(result.inputs?.action).toBe('character-animation.remove-background'); + expect( + isRemixableCanvasGenerationAction( + 'character-animation.remove-background', + ), + ).toBe(false); + }); + it('uses a character animation preview video when a video reference is requested', () => { const reference = createCanvasLayerReference( { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 68f8020c5..0a2fbb8f1 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1042,6 +1042,19 @@ 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', + ); +} + export function canSplitCharacterAnimationFrames(layer: CanvasLayer) { const resourceId = resolveRegisteredProjectResourceId(layer.resourceId); return ( diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 7c9693f4a..655444448 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -460,7 +460,10 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { generationInputs: { version: 2, action: 'character-animation.generate', - fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], + fields: [ + { id: 'prompt', title: '动作描述', value: '挥手' }, + { id: 'backgroundColor', title: '背景颜色', value: 'green' }, + ], references: [], }, }), @@ -489,6 +492,40 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { ); }); + it('hides sequence background removal unless the authoritative background is green', () => { + const whiteLayer = createLayer({ + mediaType: 'image-sequence', + assetKind: 'character-animation', + generationInputs: { + version: 2, + action: 'character-animation.generate', + fields: [ + { id: 'prompt', title: '动作描述', value: '挥手' }, + { id: 'backgroundColor', title: '背景颜色', value: 'white' }, + ], + references: [], + }, + }); + renderSelectedToolbar({ selectedLayer: whiteLayer }); + + expect(screen.queryByRole('button', { name: '去背景' })).toBeNull(); + + cleanup(); + const convertedLayer = createLayer({ + mediaType: 'image-sequence', + assetKind: 'character-animation', + generationInputs: { + version: 2, + action: 'character-animation.convert', + fields: [{ id: 'backgroundColor', title: '背景颜色', value: 'green' }], + references: [], + }, + }); + renderSelectedToolbar({ selectedLayer: convertedLayer }); + + expect(screen.queryByRole('button', { name: '去背景' })).toBeNull(); + }); + it('disables frame splitting while a character animation resource is pending registration', () => { const props = renderSelectedToolbar({ selectedLayer: createLayer({ diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 1b36ec0af..e5208699e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -16,6 +16,7 @@ import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { canOpenRedrawPanel, + canRemoveCharacterAnimationBackground, canSplitCharacterAnimationFrames, isQuickEditSupportedLayer, } from './ImageCanvasGenerationModel'; @@ -67,6 +68,8 @@ export function ImageCanvasSelectedLayerToolbarView({ return null; } const canRedraw = canOpenRedrawPanel(selectedLayer); + const canRemoveCharacterAnimation = + canRemoveCharacterAnimationBackground(selectedLayer); const isCharacterAnimationResourcePending = selectedLayer.assetKind === 'character-animation' && !canSplitCharacterAnimationFrames(selectedLayer); @@ -281,35 +284,39 @@ export function ImageCanvasSelectedLayerToolbarView({ ) : null} {selectedLayer.assetKind === 'character-animation' ? ( <> - - ) : ( - - ) - } - disabled={ - isCharacterAnimationResourcePending || - isRemovingCharacterAnimationBackground - } - aria-busy={ - isCharacterAnimationResourcePending || - isRemovingCharacterAnimationBackground - } - onClick={() => onRemoveCharacterAnimationBackground(selectedLayer)} - > - - {isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'} - - + {canRemoveCharacterAnimation ? ( + + ) : ( + + ) + } + disabled={ + isCharacterAnimationResourcePending || + isRemovingCharacterAnimationBackground + } + aria-busy={ + isCharacterAnimationResourcePending || + isRemovingCharacterAnimationBackground + } + onClick={() => + onRemoveCharacterAnimationBackground(selectedLayer) + } + > + + {isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'} + + + ) : null}