收紧序列帧去背景来源与绿幕门禁
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / Native shell tests (pull_request) Failing after 11m22s

验证去背景每帧正式资产对象的用户归属、对象路径和来源任务
仅允许编辑器生成的纯绿幕角色动画执行算法去背景
补齐去背景生成 action 白名单但保持不可改造
更新工具栏门禁、回归测试和Spine序列帧技术方案
This commit is contained in:
2026-08-15 14:50:18 +08:00
parent 2c85ea0b83
commit 101fd952d5
7 changed files with 336 additions and 30 deletions
@@ -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` 与历史结果发生幂等冲突。
@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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<EditorCharacterAnimationFramePayload> = 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");
@@ -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',
@@ -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(
{
@@ -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 (
@@ -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({
@@ -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' ? (
<>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'
}
title={
isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'
}
icon={
isRemovingCharacterAnimationBackground ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImageOff className="h-4 w-4" />
)
}
disabled={
isCharacterAnimationResourcePending ||
isRemovingCharacterAnimationBackground
}
aria-busy={
isCharacterAnimationResourcePending ||
isRemovingCharacterAnimationBackground
}
onClick={() => onRemoveCharacterAnimationBackground(selectedLayer)}
>
<span>
{isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'}
</span>
</CanvasChromeButton>
{canRemoveCharacterAnimation ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'
}
title={
isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'
}
icon={
isRemovingCharacterAnimationBackground ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImageOff className="h-4 w-4" />
)
}
disabled={
isCharacterAnimationResourcePending ||
isRemovingCharacterAnimationBackground
}
aria-busy={
isCharacterAnimationResourcePending ||
isRemovingCharacterAnimationBackground
}
onClick={() =>
onRemoveCharacterAnimationBackground(selectedLayer)
}
>
<span>
{isRemovingCharacterAnimationBackground ? '去背景中' : '去背景'}
</span>
</CanvasChromeButton>
) : null}
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={characterAnimationSplitLabel}