diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 8af4b657f..d9d018ea0 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -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( }) } -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); diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 76b18a708..ccbe6cea7 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -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 { diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index f887b7a81..f3ce432ef 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -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); diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 1ab91da71..42f52166a 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -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序列帧动画生成' }), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 1736ac63e..149734969 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -8,9 +8,9 @@ import type { EditorAssetSnapshot, EditorCharacterAnimationBackgroundColor, EditorCharacterAnimationFrameCount, - EditorCharacterAnimationResult, EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, + EditorCharacterAnimationResult, EditorImageGenerationStyle, EditorImageSequenceFrameResult, EditorPixelArtSnapInput, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 2788b7c42..3a62384ee 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -497,6 +497,7 @@ describe('ImageCanvasGenerationDialogModel', () => { assetObjectId: undefined, resourceId: 'resource-source', sourceAssetId: undefined, + assetKind: 'character', width: 1024, height: 768, }, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index e3c5d57d7..3771a4ab2 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -2393,8 +2393,6 @@ export function appendGenerationReference( } : dialog; } - const hasVideoMotionReference = - dialog.generationReferences?.[0]?.mediaType === 'video'; if (mediaType === 'audio') { return dialog; } diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index cf5f9faff..c77ebf177 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -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, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 1e767d5d4..06c1183bb 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -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( { 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 () => { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 694884a67..9acb0d3f9 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -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( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index d4483be0b..3640ca8d5 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -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({ ? '拆帧中' : '空闲'} + + + {workflow.removingBackgroundCharacterAnimationLayerIds.has( + layers[0]!.id, + ) + ? '去背景中' + : '空闲'} +