import { afterEach, describe, expect, it, vi } from 'vitest'; import { createEditorAsset, createEditorAssetFolder, createEditorProject, createEditorProjectResource, deleteEditorAsset, deleteEditorAssetFolder, deleteEditorProject, editEditorImage, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, generateEditorSoundEffect, generateEditorVideo, listEditorProjects, listPublicEditorProjectResources, loadEditorGenerationPricing, loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, removeEditorImageBackground, renameEditorProject, saveEditorProjectLayout, splitEditorIconSpritesheet, submitEditorAssetShowcase, toggleEditorShowcaseAssetLike, updateEditorAsset, updateEditorAssetFolder, updateEditorProjectResourceShowcase, } from './editorProjectClient'; const requestJsonMock = vi.hoisted(() => vi.fn()); const editorRetryOptionsExpectation = expect.objectContaining({ maxRetries: 2, retryUnsafeMethods: true, retryableStatusCodes: expect.arrayContaining([429]), }); vi.mock('../apiClient', () => ({ requestJson: requestJsonMock, })); describe('editorProjectClient', () => { afterEach(() => { requestJsonMock.mockReset(); }); it('loads the recent project without creating a duplicate when it exists', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '未命名画布', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadOrCreateRecentEditorProject(); expect(project.projectId).toBe('editor-project-1'); expect(requestJsonMock).toHaveBeenCalledTimes(1); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/recent', { method: 'GET' }, '读取图片画布工程失败', ); }); it('loads editor generation pricing without requiring auth', async () => { requestJsonMock.mockResolvedValueOnce({ models: { 'gemini-3.1-flash-image-preview': { unit: 'perGeneration', prices: { '0.5K': 9, '1K': 18, '2K': 36 }, }, 'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 29, '2K': 58 }, }, 'seedance2.0': { unit: 'perSecond', prices: { '480p': 13, '720p': 26, '1080p': 52 }, }, 'audio1.0': { unit: 'perGeneration', price: 15 }, 'chirp-v5': { unit: 'perGeneration', price: 9 }, }, }); const pricing = await loadEditorGenerationPricing(); expect(pricing.models['gpt-image-2']?.prices?.['1K']).toBe(29); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/generation-pricing', { method: 'GET' }, '读取模型定价失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('creates a default project when there is no recent project', async () => { requestJsonMock .mockResolvedValueOnce({ project: null }) .mockResolvedValueOnce({ project: { projectId: 'editor-project-created', title: '未命名画布', canvas: { canvasId: 'editor-project-created:canvas:default', projectId: 'editor-project-created', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadOrCreateRecentEditorProject(); expect(project.projectId).toBe('editor-project-created'); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/projects', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '未命名画布' }), }), '创建图片画布工程失败', ); }); it('saves viewport and layer layout through the project API', async () => { requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', updatedAt: '2026-06-12T00:00:00.000Z', }); const result = await saveEditorProjectLayout('editor-project-1', { viewport: { x: 12, y: 24, scale: 0.5 }, layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }], }); expect(result).toEqual({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', updatedAt: '2026-06-12T00:00:00.000Z', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ viewport: { x: 12, y: 24, scale: 0.5 }, layers: [ { layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }, ], }), }), '保存图片画布工程失败', ); }); it('lists editor projects from the project API', async () => { requestJsonMock.mockResolvedValueOnce({ projects: [ { projectId: 'editor-project-1', title: '角色设定板', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, ], }); const projects = await listEditorProjects(); expect(projects).toHaveLength(1); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects', { method: 'GET' }, '读取图片画布工程列表失败', ); }); it('loads public showcase project resources without requiring auth', async () => { requestJsonMock.mockResolvedValueOnce({ resources: [ { resourceId: 'resource-public-1', projectId: 'editor-project-1', ownerUserId: 'user-public-author', authorDisplayName: '公开作者', authorPublicUserCode: 'SY-00000042', label: '公开角色素材', imageSrc: '/generated-editor-assets/public.png', width: 512, height: 512, sourceType: 'generated', publicShowcaseEnabled: true, }, ], nextCursor: 'cursor-next', campaign: { enabled: true, title: '活动精选', imageSrc: '/campaign.png', imageWidth: 900, imageHeight: 1200, prompt: '活动提示词', author: '运营', costText: '50泥点', }, }); const page = await listPublicEditorProjectResources(); expect(page.resources).toHaveLength(1); expect(page.resources[0]?.resourceId).toBe('resource-public-1'); expect(page.resources[0]?.ownerUserId).toBe('user-public-author'); expect(page.resources[0]?.authorDisplayName).toBe('公开作者'); expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042'); expect(page.nextCursor).toBe('cursor-next'); expect(page.campaign?.title).toBe('活动精选'); expect(page.campaign?.imageWidth).toBe(900); expect(page.campaign?.imageHeight).toBe(1200); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, '读取陶泥儿精选素材失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('loads public showcase project resources after a cursor', async () => { requestJsonMock.mockResolvedValueOnce({ resources: [], nextCursor: null, }); const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' }); expect(page.resources).toEqual([]); expect(page.nextCursor).toBeNull(); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources?cursor=cursor-1', { method: 'GET' }, '读取陶泥儿精选素材失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('loads an explicit project by id', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '角色设定板', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadEditorProject('editor-project-1'); expect(project.viewport.scale).toBe(1.5); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', { method: 'GET' }, '读取图片画布工程失败', ); }); it('renames and deletes an editor project', async () => { requestJsonMock .mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '新标题', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }) .mockResolvedValueOnce({ deletedProjectId: 'editor-project-1' }); await renameEditorProject('editor-project-1', '新标题'); await deleteEditorProject('editor-project-1'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/projects/editor-project-1/metadata', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '新标题' }), }), '重命名图片画布工程失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/projects/editor-project-1', { method: 'DELETE' }, '删除图片画布工程失败', ); }); it('creates a resource with upload or generated metadata', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { resourceId: 'resource-generated-1', projectId: 'editor-project-1', imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', }, }); await createEditorProjectResource('editor-project-1', { imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: 'dragon knight' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1/resources', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: 'dragon knight' }], references: [], }, }), }), '创建图片画布资源失败', ); }); it('updates project resource showcase visibility', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { resourceId: 'resource-generated-1', projectId: 'editor-project-1', label: '角色素材', imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', publicShowcaseEnabled: false, }, }); const resource = await updateEditorProjectResourceShowcase( 'resource-generated-1', false, ); expect(resource.publicShowcaseEnabled).toBe(false); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/project-resources/resource-generated-1/showcase', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publicShowcaseEnabled: false }), }), '更新素材公开展示状态失败', ); }); it('submits account assets to showcase review', async () => { requestJsonMock.mockResolvedValueOnce({ showcaseAsset: { resourceId: 'showcase-asset-1', showcaseId: 'showcase-asset-1', assetId: 'asset-1', projectId: 'editor-showcase', imageSrc: '/generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', reviewStatus: 'pending', displayEnabled: false, likeCount: 0, generationCostMudPoints: 20, refundMudPoints: 10, }, }); const showcaseAsset = await submitEditorAssetShowcase('asset-1'); expect(showcaseAsset.showcaseId).toBe('showcase-asset-1'); expect(showcaseAsset.reviewStatus).toBe('pending'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/assets/asset-1/showcase-submissions', { method: 'POST' }, '提交精选审核失败', ); }); it('toggles showcase asset likes', async () => { requestJsonMock.mockResolvedValueOnce({ showcaseAsset: { resourceId: 'showcase-asset-1', showcaseId: 'showcase-asset-1', assetId: 'asset-1', projectId: 'editor-showcase', imageSrc: '/generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', reviewStatus: 'approved', displayEnabled: true, likeCount: 8, generationCostMudPoints: 20, refundMudPoints: 10, }, }); const showcaseAsset = await toggleEditorShowcaseAssetLike( 'showcase-asset-1', true, ); expect(showcaseAsset.likeCount).toBe(8); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/assets/showcase-asset-1/likes', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ liked: true }), }), '点赞精选素材失败', ); }); it('loads and mutates the account-level asset library', async () => { requestJsonMock .mockResolvedValueOnce({ library: { folders: [ { folderId: 'folder-project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }, }) .mockResolvedValueOnce({ folder: { folderId: 'folder-role', label: '角色', sortOrder: 100, collapsed: false, systemDefault: false, }, }) .mockResolvedValueOnce({ folder: { folderId: 'folder-role', label: '角色参考', sortOrder: 100, collapsed: true, systemDefault: false, }, }) .mockResolvedValueOnce({ library: { folders: [], assets: [], }, }); await loadEditorAssetLibrary(); await createEditorAssetFolder('角色', 100); await updateEditorAssetFolder('folder-role', { label: '角色参考', collapsed: true, }); await deleteEditorAssetFolder('folder-role'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/assets/library', { method: 'GET' }, '读取图片画布素材库失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/assets/folders', expect.objectContaining({ method: 'POST', body: JSON.stringify({ label: '角色', sortOrder: 100 }), }), '创建图片画布素材文件夹失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 3, '/api/editor/assets/folders/folder-role', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ label: '角色参考', collapsed: true }), }), '更新图片画布素材文件夹失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 4, '/api/editor/assets/folders/folder-role', { method: 'DELETE' }, '删除图片画布素材文件夹失败', ); }); it('creates, updates, and deletes account-level image assets', async () => { requestJsonMock .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }) .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-role', label: '角色主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }) .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-role', label: '角色主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }); await createEditorAsset({ folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', assetKind: 'icon', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }); await updateEditorAsset('asset-1', { label: '角色主视觉.png', folderId: 'folder-role', }); await deleteEditorAsset('asset-1'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/assets', expect.objectContaining({ method: 'POST', body: JSON.stringify({ folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', assetKind: 'icon', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }), }), '创建图片画布素材失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/assets/asset-1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ label: '角色主视觉.png', folderId: 'folder-role', }), }), '更新图片画布素材失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 3, '/api/editor/assets/asset-1', { method: 'DELETE' }, '删除图片画布素材失败', ); }); it('creates an explicit project from title input', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-explicit', title: '角色设定板', canvas: { canvasId: 'editor-project-explicit:canvas:default', projectId: 'editor-project-explicit', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); await createEditorProject({ title: '角色设定板' }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '角色设定板' }), }), '创建图片画布工程失败', ); }); it('generates editor images through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,abc', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张画布图片', actualPrompt: '一张画布图片', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-task-1', }); const result = await generateEditorImage({ prompt: '一张画布图片', }); expect(result.taskId).toBe('vector-task-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes image model options to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', width: 1024, height: 1536, sourceType: 'generated', prompt: '角色设定', actualPrompt: '角色设定', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-character-1', }); await generateEditorImage({ prompt: '角色设定', kind: 'character', model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', assetLabel: '角色形象', sourceResourceId: 'resource-spec-1', generationInputs: { fields: [{ title: '角色设定', value: '角色设定' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '角色设定', kind: 'character', model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '角色设定' }], references: [], }, assetFolderId: 'project', assetLabel: '角色形象', sourceResourceId: 'resource-spec-1', }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes canvas completion context to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', width: 1024, height: 1536, sourceType: 'generated', prompt: '角色设定', actualPrompt: '角色设定', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-character-1', project: null, }); await generateEditorImage({ prompt: '角色设定', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', canvasCompletion: { dialogId: 'generation-dialog-1', title: '角色形象 1', placeholder: { x: 120, y: 140, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '角色设定', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', canvasCompletion: { dialogId: 'generation-dialog-1', title: '角色形象 1', placeholder: { x: 120, y: 140, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates icon spritesheets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], sliceWarning: { code: 'insufficient-connected-components', reason: '连通域数量不足', }, prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-1', }); const result = await generateEditorIconSpritesheet({ referenceImageSrc: 'data:image/png;base64,spec', referenceImageSrcs: ['data:image/png;base64,icon-ref'], iconDescriptions: ['返回按钮', '设置按钮'], }); expect(result.taskId).toBe('icon-spritesheet-task-1'); expect(result.sliceWarning).toEqual({ code: 'insufficient-connected-components', reason: '连通域数量不足', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ referenceImageSrc: 'data:image/png;base64,spec', referenceImageSrcs: ['data:image/png;base64,icon-ref'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', }), }), '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes image model options to icon spritesheet generation', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [], prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-2', }); await generateEditorIconSpritesheet({ referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', assetFolderId: 'project', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, assetFolderId: 'project', }), }), '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('splits an existing icon spritesheet into project assets', async () => { const project = { projectId: 'editor-project-1', title: '未命名画布', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-13T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-13T00:00:00.000Z', }; requestJsonMock.mockResolvedValueOnce({ iconImageSrcs: [ { name: '素材 1', imageSrc: '/generated/atlas/asset-001.png', width: 128, height: 128, }, ], project, }); const result = await splitEditorIconSpritesheet({ projectId: 'editor-project-1', sourceResourceId: 'resource-atlas-1', assetFolderId: 'project', canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 80, width: 360, height: 360, originalWidth: 1024, originalHeight: 1024, }, }, }); expect(result.project).toBe(project); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/slices', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: 'editor-project-1', sourceResourceId: 'resource-atlas-1', assetFolderId: 'project', canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 80, width: 360, height: 360, originalWidth: 1024, originalHeight: 1024, }, }, }), }), '拆分图集失败', ); }); it('extracts UI design assets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [ { name: '素材 1', imageSrc: 'data:image/png;base64,asset-1', width: 128, height: 128, }, ], prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-asset-extraction-1', }); const result = await extractEditorUiDesignAssets({ sourceImageSrc: 'data:image/png;base64,ui-design', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', referenceImageSrcs: ['data:image/png;base64,ui-ref'], projectId: 'editor-project-1', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', aspectRatio: '1:1', imageSize: '2K', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], references: [], }, }); expect(result.taskId).toBe('ui-asset-extraction-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/ui-designs/assets/extractions', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceImageSrc: 'data:image/png;base64,ui-design', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', referenceImageSrcs: ['data:image/png;base64,ui-ref'], projectId: 'editor-project-1', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], references: [], }, assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', aspectRatio: '1:1', imageSize: '2K', }), }), '提取UI设计图素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes spec generation size and kind to the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,spec', width: 2048, height: 1152, sourceType: 'generated', prompt: '生成规范图', actualPrompt: '生成规范图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-spec-1', }); const result = await generateEditorImage({ prompt: '生成规范图', size: '2048x1152', kind: 'spec', model: 'gpt-image-2', }); expect(result.width).toBe(2048); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '生成规范图', size: '2048x1152', kind: 'spec', model: 'gpt-image-2', }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes publication material generation kind and size to the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 1280, sourceType: 'generated', prompt: '宣发素材 prompt', actualPrompt: '宣发素材 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-publication-1', }); const result = await generateEditorImage({ prompt: '宣发素材 prompt', size: '720x1280', kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], }); expect(result.height).toBe(1280); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '宣发素材 prompt', size: '720x1280', kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates editor character animations through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ taskId: 'character-animation-1', model: 'seedance2.0-fast', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, ], frameCount: 32, durationSeconds: 4, fps: 8, }); const result = await generateEditorCharacterAnimation({ sourceLayerId: 'layer-character', sourceImageSrc: 'data:image/png;base64,character', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', }); expect(result.taskId).toBe('character-animation-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/character-animations/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceLayerId: 'layer-character', sourceImageSrc: 'data:image/png;base64,character', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', }), }), '生成角色动画失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates editor videos through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ videoSrc: '/generated-editor-videos/video-task-1/preview.mp4', width: 1280, height: 720, sourceType: 'generated', prompt: '让角色向镜头挥手', actualPrompt: '让角色向镜头挥手', model: 'kling3.0-omni', provider: 'VectorEngine', taskId: 'video-task-1', durationSeconds: 5, resolution: '480p', thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', asset: { assetId: 'asset-video-1', folderId: 'user-1:asset-folder:project', label: '生成视频 1', imageSrc: '/generated-editor-videos/video-task-1/preview.mp4', thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', width: 1280, height: 720, sourceType: 'generated', assetKind: 'video', }, }); const result = await generateEditorVideo({ prompt: '让角色向镜头挥手', model: 'kling3.0-omni', aspectRatio: '16:9', durationSeconds: 5, resolution: '480p', mode: 'std', sound: 'off', assetFolderId: 'project', assetLabel: '生成视频 1', }); expect(result.taskId).toBe('video-task-1'); expect(result.thumbnailSrc).toBe( '/generated-editor-videos/video-task-1/cover.png', ); expect(result.asset?.assetId).toBe('asset-video-1'); expect(result.asset?.thumbnailSrc).toBe( '/generated-editor-videos/video-task-1/cover.png', ); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/videos/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '让角色向镜头挥手', model: 'kling3.0-omni', aspectRatio: '16:9', durationSeconds: 5, resolution: '480p', mode: 'std', sound: 'off', webSearchEnabled: true, assetFolderId: 'project', assetLabel: '生成视频 1', }), }), '生成视频失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes seedance video multimodal references through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ videoSrc: '/generated-editor-videos/video-task-2/preview.mp4', width: 1280, height: 720, sourceType: 'generated', prompt: '参考素材生成视频', actualPrompt: '参考素材生成视频', model: 'seedance2.0-fast', provider: 'VectorEngine', taskId: 'video-task-2', durationSeconds: 5, resolution: '720p', }); await generateEditorVideo({ prompt: '参考素材生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', referenceImageSrcs: ['data:image/png;base64,image'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], referenceAudioSrcs: [ 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', ], }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/videos/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '参考素材生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', webSearchEnabled: true, referenceImageSrcs: ['data:image/png;base64,image'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], referenceAudioSrcs: [ 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', ], }), }), '生成视频失败', expect.objectContaining({ timeoutMs: 1_200_000 }), ); }); it('generates editor sound effects through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '金币掉落叮当声', actualPrompt: '金币掉落叮当声', model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-1', audioKind: 'sound-effect', asset: { assetId: 'asset-sound-1', folderId: 'user-1:asset-folder:project', label: '游戏音效 1', imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', width: 420, height: 120, sourceType: 'generated', assetKind: 'sound-effect', }, }); const result = await generateEditorSoundEffect({ prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, assetFolderId: 'project', assetLabel: '游戏音效 1', }); expect(result.taskId).toBe('sound-task-1'); expect(result.asset?.assetId).toBe('asset-sound-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, assetFolderId: 'project', assetLabel: '游戏音效 1', }), }), '生成游戏音效失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('sends editor sound effect duration to Vidu instead of legacy type and tempo', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '按钮确认短促音', actualPrompt: '按钮确认短促音', model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-null', audioKind: 'sound-effect', }); await generateEditorSoundEffect({ prompt: '按钮确认短促音', model: 'audio1.0', duration: 5, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '按钮确认短促音', model: 'audio1.0', duration: 5, }), }), '生成游戏音效失败', expect.any(Object), ); }); it('generates editor background music through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '森林冒险背景音乐', actualPrompt: '森林冒险背景音乐', model: 'chirp-v4', provider: 'VectorEngine', taskId: 'music-task-1', audioKind: 'background-music', }); const result = await generateEditorBackgroundMusic({ gptDescriptionPrompt: '森林冒险背景音乐', makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1', }); expect(result.taskId).toBe('music-task-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/background-music/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gptDescriptionPrompt: '森林冒险背景音乐', makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1', }), }), '生成游戏背景音乐失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('edits editor images through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,edited', width: 1024, height: 1024, sourceType: 'generated', prompt: '把画面改成黄昏光线', actualPrompt: '把画面改成黄昏光线', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-edit-1', }); const result = await editEditorImage({ prompt: '把画面改成黄昏光线', sourceImageSrc: 'data:image/png;base64,source', size: '1024x1024', model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,style'], projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', assetLabel: '角色形象 修改结果', sourceResourceId: 'resource-character-1', targetLayerId: 'layer-character-1', generationInputs: { fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }], references: [], }, }); expect(result.taskId).toBe('vector-edit-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/edits', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '把画面改成黄昏光线', sourceImageSrc: 'data:image/png;base64,source', size: '1024x1024', model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,style'], projectId: 'editor-project-1', assetKind: 'character', generationInputs: { fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }], references: [], }, assetFolderId: 'project', assetLabel: '角色形象 修改结果', sourceResourceId: 'resource-character-1', targetLayerId: 'layer-character-1', }), }), '修改图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes canvas completion context to background removal', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,cutout', width: 512, height: 512, objectKey: 'generated-character-drafts/editor-cutouts/cutout.png', assetObjectId: 'asset-object-cutout', taskId: 'background-removal-1', elapsedMs: 1200, provider: 'BiRefNet', project: null, }); await removeEditorImageBackground({ sourceImageSrc: 'data:image/png;base64,source', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/background-removals', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceImageSrc: 'data:image/png;base64,source', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }), }), '去除背景失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); });