feat: 接入分镜导演设计与即时视觉状态工作台

增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。
补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
GouJ
2026-08-28 14:14:25 +08:00
parent 2ee6f049f6
commit bae69c3c62
22 changed files with 2211 additions and 11 deletions
+61
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, optionalResource, request } from './http'
import { projectsApi } from '../features/projects/api'
import { breakdownApi } from '../features/breakdown/api'
import { storyboardApi } from '../features/storyboard/api'
afterEach(() => {
vi.unstubAllGlobals()
@@ -9,6 +10,66 @@ afterEach(() => {
})
describe('后端 API 契约', () => {
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await storyboardApi.generateDirection('a/b', 2)
await storyboardApi.generateDirections('p', { concurrency: 2, force: false })
await storyboardApi.generateVisualState('p', 2, 0)
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }],
['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
[
'/api/projects/p/storyboard-visual-states/generate-test',
'POST',
{ episodeNo: 2, persist: true, maxRepairAttempts: 0 }
],
[
'/api/projects/p/storyboard-visual-states/generate',
'POST',
{ concurrency: 3, force: true, maxRepairAttempts: 0 }
]
])
})
it('查询按剧集,镜头工具使用数据库 ID,读取提示词仍需显式 POST', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await storyboardApi.directions('p', 4)
await storyboardApi.visualStates('p', 4)
await storyboardApi.references('shot/id')
await storyboardApi.generationSpec('shot/id')
await storyboardApi.generatePrompt('shot/id', false)
await storyboardApi.generatePrompts('p', { concurrency: 2, force: false })
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/p/storyboard-directions?episodeNo=4',
'/api/projects/p/storyboard-visual-states?episodeNo=4',
'/api/storyboard-shots/shot%2Fid/references',
'/api/storyboard-shots/shot%2Fid/generation-spec',
'/api/storyboard-shots/shot%2Fid/video-prompt',
'/api/projects/p/video-prompts/generate'
])
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false })
})
it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => {
vi.useFakeTimers()
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
const pending = storyboardApi.generateVisualState('p', 1, 2)
await vi.advanceTimersByTimeAsync(120_000)
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}'))
await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false })
})
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
const fetcher = vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()