修复序列帧后处理结果应用与存储幂等

视频直接转换和序列帧去背景按 operation 隔离对象存储路径
队列后处理失败统一返回脱敏用户文案
inline 转换和去背景结果直接应用服务端项目快照
修复去背景加载状态和相关前端回归测试
This commit is contained in:
2026-08-15 14:27:55 +08:00
parent 13afdd7a3b
commit 2c85ea0b83
13 changed files with 196 additions and 40 deletions
@@ -1235,7 +1235,7 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
// 同一 job 的重放必须复用对象路径;新的去背景 job 必须生成一套新序列对象。
// 不能使用 request fingerprint:用户再次对同一源序列执行去背景时 fingerprint 相同,
// 会复用上一任务已登记的 object location,并与新 job 的稳定 asset_object id 冲突。
let storage_task_id = editor_character_animation_background_removal_storage_task_id(
let storage_task_id = editor_character_animation_operation_storage_task_id(
owner_user_id.as_str(),
task_id.as_str(),
);
@@ -2281,12 +2281,11 @@ pub(crate) async fn convert_editor_character_animation_video_for_owner(
let operation = editor_generation_operation(&caller)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let task_id = operation.operation_id.clone();
// operation_id 用于持久化幂等;帧对象路径还绑定请求 fingerprint。相同 request id
// 的不同请求不会互相覆盖,重试同一请求仍能重用路径
let storage_task_id = format!(
"{}-{}",
sanitize_storage_segment(owner_user_id.as_str(), "owner"),
operation.operation_fingerprint
// 同一 operation 的重放必须复用对象路径;新的转换 operation 即使 payload 相同,
// 也必须生成一套新序列对象,避免旧 object location 与新 asset_object id 冲突
let storage_task_id = editor_character_animation_operation_storage_task_id(
owner_user_id.as_str(),
task_id.as_str(),
);
if let Some(reporter) = caller.phase_reporter.as_ref() {
reporter
@@ -5665,7 +5664,7 @@ fn editor_inline_generation_caller<T: Serialize>(
})
}
fn editor_character_animation_background_removal_storage_task_id(
fn editor_character_animation_operation_storage_task_id(
owner_user_id: &str,
operation_id: &str,
) -> String {
@@ -9906,7 +9905,7 @@ mod tests {
);
}
assert!(body.contains("price_mud_points: 0"));
assert!(body.contains("let storage_task_id = format!"));
assert!(body.contains("editor_character_animation_operation_storage_task_id"));
assert!(body.contains("storage_task_id.as_str(),"));
assert!(body.contains("cleanup_uncommitted_character_animation_objects"));
}
@@ -9982,13 +9981,10 @@ mod tests {
}
#[test]
fn editor_character_animation_background_removal_storage_path_is_task_scoped() {
let first =
editor_character_animation_background_removal_storage_task_id("user-1", "task-1");
let replay =
editor_character_animation_background_removal_storage_task_id("user-1", "task-1");
let next_job =
editor_character_animation_background_removal_storage_task_id("user-1", "task-2");
fn editor_character_animation_operation_storage_path_is_task_scoped() {
let first = editor_character_animation_operation_storage_task_id("user-1", "task-1");
let replay = editor_character_animation_operation_storage_task_id("user-1", "task-1");
let next_job = editor_character_animation_operation_storage_task_id("user-1", "task-2");
assert_eq!(first, replay);
assert_ne!(first, next_job);
@@ -21,7 +21,9 @@ use spacetime_client::{
use crate::editor_generation_queue::{
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -226,6 +228,12 @@ pub(crate) fn user_visible_external_generation_error(
if job_kind == EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND && error.is_some() {
return Some("角色动作生成失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND && error.is_some() {
return Some("视频直接转换失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND && error.is_some() {
return Some("序列帧去背景失败,请稍后重试。".to_string());
}
// 音效失败文本来自 ElevenLabs / reqwest,会带上请求端点、底层错误链和上游状态码。
if job_kind == EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND && error.is_some() {
return Some("音效生成失败,请稍后重试。".to_string());
@@ -511,6 +519,33 @@ mod tests {
assert!(!lower.contains("provider"));
}
#[test]
fn character_animation_postprocessing_failures_hide_internal_details_from_owner() {
for (job_kind, expected) in [
(
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND,
"视频直接转换失败,请稍后重试。",
),
(
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND,
"序列帧去背景失败,请稍后重试。",
),
] {
let message = user_visible_external_generation_error(
job_kind,
Some("provider=aliyun-oss objectKey=users/user-1/private.png HTTP 502".to_string()),
);
assert_eq!(message.as_deref(), Some(expected));
let lower = message
.expect("失败任务应返回稳定文案")
.to_ascii_lowercase();
for forbidden in ["aliyun", "objectkey", "users/user-1", "http"] {
assert!(!lower.contains(forbidden));
}
}
}
#[test]
fn sound_effect_failure_hides_provider_and_transport_details_from_owner() {
let job = ExternalGenerationJobSummaryRecord {
@@ -73,7 +73,7 @@ describe('ImageCanvasBottomToolbarView', () => {
);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
).toHaveLength(12);
).toHaveLength(13);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
).toHaveLength(2);
@@ -3452,7 +3452,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
fireEvent.click(characterLayer.closest('button')!);
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
@@ -8,9 +8,9 @@ import type {
EditorAssetSnapshot,
EditorCharacterAnimationBackgroundColor,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorCharacterAnimationResult,
EditorImageGenerationStyle,
EditorImageSequenceFrameResult,
EditorPixelArtSnapInput,
@@ -497,6 +497,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
assetObjectId: undefined,
resourceId: 'resource-source',
sourceAssetId: undefined,
assetKind: 'character',
width: 1024,
height: 768,
},
@@ -2393,8 +2393,6 @@ export function appendGenerationReference(
}
: dialog;
}
const hasVideoMotionReference =
dialog.generationReferences?.[0]?.mediaType === 'video';
if (mediaType === 'audio') {
return dialog;
}
@@ -31,9 +31,9 @@ import {
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
canOpenRedrawPanel,
createCanvasLayerReference,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import { ApiClientError } from '../../services/apiClient';
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
@@ -4532,12 +4533,21 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('submits direct video conversion without prompt or model compatibility fields', async () => {
it('applies the inline direct-conversion project without creating a duplicate local layer', async () => {
const appendedLayers = vi.fn();
const frames = [
{ imageSrc: '/converted/frame-1.png', width: 640, height: 360 },
{ imageSrc: '/converted/frame-2.png', width: 640, height: 360 },
];
const applyProjectSnapshot = vi.fn();
const backendProject: EditorProjectSnapshot = {
projectId: 'editor-project-1',
title: '视频转换项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-15T00:00:00.000Z',
};
convertEditorCharacterAnimationVideoMock.mockResolvedValueOnce({
resultType: 'video-conversion',
taskId: 'conversion-task-1',
@@ -4561,6 +4571,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
imageSequenceFrames: frames,
imageSequenceDurationMs: 2_500,
},
project: backendProject,
});
const sourceReference = {
id: 'conversion-source',
@@ -4574,6 +4585,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onAppendCanvasLayers={appendedLayers}
initialDialog={{
id: 'dialog-video-conversion',
@@ -4626,18 +4638,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(request).not.toHaveProperty('prompt');
expect(request).not.toHaveProperty('promptText');
expect(request).not.toHaveProperty('model');
await waitFor(() => expect(appendedLayers).toHaveBeenCalledTimes(1));
const layer = appendedLayers.mock.calls[0]?.[0]?.[0];
expect(layer).toMatchObject({
taskId: 'conversion-task-1',
provider: 'FFmpeg',
imageSequenceDurationMs: 2_500,
assetKind: 'character-animation',
mediaType: 'image-sequence',
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
});
expect(layer.prompt).toBeUndefined();
expect(layer.actualPrompt).toBeUndefined();
expect(layer.model).toBeUndefined();
expect(appendedLayers).not.toHaveBeenCalled();
});
it('does not reuse animation lineage as the direct conversion source resource', async () => {
@@ -2809,6 +2809,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
return;
}
if (result.project && applyProjectSnapshot) {
applyProjectSnapshot(result.project);
if (result.asset) {
upsertGeneratedAsset?.(result.asset);
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? { ...currentPanel, status: 'completed', result }
: currentPanel,
);
return;
}
if (canvasDialog) {
addCharacterAnimationResultLayer(
result,
@@ -3070,6 +3082,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
onQueuedGenerationTask,
onGenerationWarning,
upsertGeneratedAsset,
]);
return useMemo(
@@ -52,6 +52,7 @@ const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const splitEditorCharacterAnimationFramesMock = vi.hoisted(() => vi.fn());
const removeEditorCharacterAnimationBackgroundMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
@@ -91,6 +92,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
splitEditorCharacterAnimationFrames:
splitEditorCharacterAnimationFramesMock,
removeEditorCharacterAnimationBackground:
removeEditorCharacterAnimationBackgroundMock,
};
});
@@ -941,6 +944,21 @@ function GenerationWorkflowHarness({
? '拆帧中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!)
}
>
</button>
<output aria-label="序列帧去背景状态">
{workflow.removingBackgroundCharacterAnimationLayerIds.has(
layers[0]!.id,
)
? '去背景中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
@@ -2988,6 +3006,89 @@ describe('useImageCanvasGenerationWorkflow', () => {
},
);
it('applies an inline character-animation background-removal project and publishes removal state', async () => {
const deferred = createDeferred<{
taskId: string;
frames: Array<{ imageSrc: string; width: number; height: number }>;
frameCount: number;
durationMs: number;
frameWidth: number;
frameHeight: number;
priceMudPoints: number;
project: EditorProjectSnapshot;
}>();
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const backendProject: EditorProjectSnapshot = {
projectId: 'project-1',
title: '序列帧去背景项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-source',
resourceId: 'resource-source',
},
],
resources: [],
updatedAt: '2026-08-15T00:00:00.000Z',
};
removeEditorCharacterAnimationBackgroundMock.mockReturnValueOnce(
deferred.promise,
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
refreshAssetLibrary={refreshAssetLibrary}
initialLayers={[
createLayer({
title: '勇者奔跑',
assetKind: 'character-animation',
mediaType: 'image-sequence',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '去除序列帧背景' }));
expect(
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
).toBe('去背景中');
expect(removeEditorCharacterAnimationBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
sourceLayerId: 'layer-source',
sourceResourceId: 'resource-source',
}),
);
deferred.resolve({
taskId: 'background-removal-task',
frames: [
{ imageSrc: '/frames/1.png', width: 320, height: 240 },
{ imageSrc: '/frames/2.png', width: 320, height: 240 },
],
frameCount: 2,
durationMs: 2_000,
frameWidth: 320,
frameHeight: 240,
priceMudPoints: 0,
project: backendProject,
});
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject, {
type: 'remove-background',
count: 1,
});
});
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
expect(
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
).toBe('空闲');
});
it('queues background removal for private character images', async () => {
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,resolved-character',
@@ -3730,7 +3730,7 @@ export function useImageCanvasGenerationWorkflow({
sourceLayer,
),
});
await applyQueuedEditorGenerationProject(
const queued = await applyQueuedEditorGenerationProject(
result,
projectId,
(project) =>
@@ -3745,6 +3745,15 @@ export function useImageCanvasGenerationWorkflow({
(project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
);
if (!queued && result.project && applyProjectSnapshot) {
applyProjectSnapshot(
preserveSourceLayerInProjectSnapshot(result.project, sourceLayer),
{
type: 'remove-background',
count: 1,
},
);
}
try {
await Promise.resolve(refreshAssetLibrary?.());
} catch {
@@ -3870,9 +3879,6 @@ export function useImageCanvasGenerationWorkflow({
let rejectedCharacterReference: 'video' | 'character' | null = null;
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode === 'character-animation') {
const isVideoAppearanceReference =
currentDialog.characterAnimationWorkflow !== 'video-conversion' &&
currentDialog.generationReferences?.[0]?.mediaType === 'video';
const isAllowedCharacterAnimationReference =
currentDialog.characterAnimationWorkflow === 'video-conversion'
? layer.mediaType === 'video' ||
@@ -4995,6 +5001,8 @@ export function useImageCanvasGenerationWorkflow({
splittingIconSpritesheetLayerIds,
splitSelectedCharacterAnimationFrames,
splittingCharacterAnimationLayerIds,
removeSelectedCharacterAnimationBackground,
removingBackgroundCharacterAnimationLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
@@ -3,6 +3,7 @@
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import {
completeEditorBackgroundMusicPrompt,
convertEditorCharacterAnimationVideo,
createEditorAsset,
createEditorAssetFolder,
createEditorProject,
@@ -19,7 +20,6 @@ import {
EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
convertEditorCharacterAnimationVideo,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,