限制序列帧去背景仅用于视频转换

后端仅接受 character-animation.convert 结果执行序列帧去背景

前端工具栏与工作流同步收紧视频转换门禁

更新相关测试与Spine序列帧技术方案
This commit is contained in:
2026-08-15 15:35:27 +08:00
parent b5dc99e067
commit f65a813768
7 changed files with 57 additions and 73 deletions
@@ -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` 与历史结果发生幂等冲突。
@@ -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]
@@ -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);
@@ -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'
);
}
@@ -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', () => {
@@ -3067,6 +3067,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
title: '勇者奔跑',
assetKind: 'character-animation',
mediaType: 'image-sequence',
generationInputs: {
version: 2,
action: 'character-animation.convert',
fields: [],
references: [],
},
}),
]}
/>,
@@ -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(