限制序列帧去背景仅用于视频转换
后端仅接受 character-animation.convert 结果执行序列帧去背景 前端工具栏与工作流同步收紧视频转换门禁 更新相关测试与Spine序列帧技术方案
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user