收紧序列帧去背景来源与绿幕门禁
验证去背景每帧正式资产对象的用户归属、对象路径和来源任务 仅允许编辑器生成的纯绿幕角色动画执行算法去背景 补齐去背景生成 action 白名单但保持不可改造 更新工具栏门禁、回归测试和Spine序列帧技术方案
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user