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
+361
View File
@@ -9,6 +9,16 @@ import ProjectsPage from '../projects/ProjectsPage.vue'
import { projectContextKey, type useProjectData } from '../projects/context'
import type { ProjectDetail } from '../projects/types'
import type { Checkpoint } from './types'
import StoryboardPage from '../storyboard/StoryboardPage.vue'
import ShotTools from '../storyboard/components/ShotTools.vue'
import {
designedShot,
directionsResult,
storyboardCheckpoint,
visualStatesResult
} from '../storyboard/testing/fixtures'
import { getStoryboardSession } from '../storyboard/model'
import { getOperation } from './operations'
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = {
@@ -48,14 +58,365 @@ function button(label: string): HTMLButtonElement {
return element
}
/** 走真实确认弹窗,包括后端任务停止的显式确认。 */
async function confirmGeneration(label: string) {
button(label).click()
await flushPromises()
const acknowledgement = document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')
expect(button('确认' + label).disabled).toBe(true)
acknowledgement!.click()
await flushPromises()
button('确认' + label).click()
await flushPromises()
}
/** 挂载分镜页面并复用项目上下文 fixture。 */
function mountStoryboard() {
const provided = context()
provided.data.value!.checkpoints = [storyboardCheckpoint()]
wrapper = mount(StoryboardPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
})
return provided
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
})
describe('工作台页面交互', () => {
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
const shot = designedShot()
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
shotId: shot.shotId,
references: [
{
shotSubjectId: 'binding-db',
subjectId: 'subject-db',
subjectRef: '@CH0001',
subjectName: '林知夏',
module: 'character',
subjectFormId: 'form-db',
subjectFormName: '日常',
imageId: 'image-db',
imageUrl: 'javascript:alert(1)'
}
],
missing: []
}
})
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
projectId: fixture.id,
episodeNo: 1,
beatNo: 1,
shotId: shot.shotId,
shotNo: 1,
title: '来信',
description: '真实镜头',
visualFocus: '信封',
durationSeconds: 5,
subjects: [{ subjectId: 'subject-db', subjectFormId: 'form-db' }],
direction: shot.direction,
environment: {
timeOfDay: '夜晚',
weather: '小雨',
atmosphere: '安静',
transientState: '地面积水'
},
continuityNote: '信封在右手'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ShotTools, { attachTo: document.body, props: { projectId: fixture.id, shot, disabled: false } })
button('读取参考图').click()
await flushPromises()
expect(wrapper.text()).toContain('图片不可用')
expect(wrapper.find('.reference-card a').exists()).toBe(false)
expect(wrapper.find('.reference-card img').exists()).toBe(false)
button('读取生成规格').click()
await flushPromises()
expect(wrapper.get('pre').text()).toContain('form-db')
expect(wrapper.get('pre').text()).toContain('地面积水')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/generation-spec')
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
})
it('生成途中切换剧集并离开页面,不改变提交目标,也不丢失项目回执', async () => {
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
if (init?.method === 'POST')
return new Promise(resolve => {
finish = resolve
})
const episodeNo = Number(new URL(String(url), 'http://localhost').searchParams.get('episodeNo'))
return Promise.resolve(
new Response(
JSON.stringify({
data: String(url).includes('storyboard-directions')
? directionsResult(fixture.id, episodeNo)
: visualStatesResult(fixture.id, episodeNo)
})
)
)
})
vi.stubGlobal('fetch', fetcher)
mountStoryboard()
await flushPromises()
await confirmGeneration('生成本集导演设计')
expect(button('补齐项目导演设计').disabled).toBe(true)
await wrapper!.get('select').setValue('2')
await flushPromises()
wrapper!.unmount()
wrapper = undefined
finish(
new Response(
'{"data":{"episodeNo":1,"direction":{"episodeNo":1,"beatDirections":[]},"validation":{"valid":true,"issues":[]}}}'
)
)
await flushPromises()
expect(getStoryboardSession(fixture.id).receipt).toMatchObject({
kind: 'direction',
episodeNo: 1,
title: '第 1 集导演设计'
})
expect(getOperation(fixture.id).pending).toBe(false)
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts).toHaveLength(1)
expect(JSON.parse(posts[0]![1]!.body as string)).toEqual({ episodeNo: 1, persist: true })
})
it('分镜必须先补齐导演设计,校验失败显示未保存,修复次数 0 不被默认值覆盖', async () => {
let complete = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'POST')
return new Response(
JSON.stringify({
data: {
episodeNo: 1,
visualState: {},
validation: {
valid: false,
issues: [{ episodeNo: 1, beatNo: 1, shotNo: 1, message: '缺少主体状态' }]
},
repaired: false,
repairAttempts: 0,
remainingIssues: [],
persisted: false
}
})
)
const result = String(url).includes('storyboard-directions')
? directionsResult(fixture.id, 1, complete)
: visualStatesResult(fixture.id)
return new Response(JSON.stringify({ data: result }))
})
vi.stubGlobal('fetch', fetcher)
mountStoryboard()
await flushPromises()
expect(button('生成本集导演设计').disabled).toBe(false)
expect(button('生成本集即时状态').disabled).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
complete = true
button('刷新分镜').click()
await flushPromises()
expect(button('生成本集即时状态').disabled).toBe(false)
await wrapper!.get('#storyboard-repairs').setValue('0')
await confirmGeneration('生成本集即时状态')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1, persist: true, maxRepairAttempts: 0 })
expect(wrapper!.text()).toContain('校验失败,未保存本次生成结果')
expect(wrapper!.text()).toContain('缺少主体状态')
})
it('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'POST')
return new Response(
JSON.stringify({
data: {
projectId: fixture.id,
episodeCount: 2,
completedEpisodes: 0,
skippedEpisodes: 1,
failedEpisodes: 1,
shotCount: 4,
directionCount: 2,
coverage: 0.5,
episodes: [
{ episodeNo: 1, status: 'skipped', shotCount: 2, directionCount: 2 },
{
episodeNo: 2,
status: 'failed',
shotCount: 2,
directionCount: 0,
error: '模型校验失败'
}
]
}
})
)
return new Response(
JSON.stringify({
data: String(url).includes('storyboard-directions')
? directionsResult(fixture.id)
: visualStatesResult(fixture.id)
})
)
})
vi.stubGlobal('fetch', fetcher)
mountStoryboard()
await flushPromises()
await confirmGeneration('补齐项目导演设计')
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
expect(wrapper!.text()).toContain('模型校验失败')
await wrapper!.get('input[type="checkbox"]').setValue(true)
await confirmGeneration('重生成全部导演设计')
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
{ concurrency: 2, force: false },
{ concurrency: 2, force: true }
])
})
it('切换剧集取消旧查询,迟到响应不会覆盖新剧集,也不自动提交生成', async () => {
const oldRequests: { finish: (response: Response) => void; url: string; signal: AbortSignal }[] = []
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
const address = String(url)
if (address.endsWith('episodeNo=1'))
return new Promise(resolve =>
oldRequests.push({ finish: resolve, url: address, signal: init!.signal as AbortSignal })
)
return Promise.resolve(
new Response(
JSON.stringify({
data: address.includes('storyboard-directions')
? directionsResult(fixture.id, 2)
: visualStatesResult(fixture.id, 2)
})
)
)
})
vi.stubGlobal('fetch', fetcher)
mountStoryboard()
await flushPromises()
await wrapper!.get('select[aria-label="选择分镜剧集"]').setValue('2')
await flushPromises()
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
for (const request of oldRequests)
request.finish(
new Response(
JSON.stringify({
data: request.url.includes('storyboard-directions')
? directionsResult(fixture.id)
: visualStatesResult(fixture.id)
})
)
)
await flushPromises()
expect(wrapper!.text()).toContain('shot-db-2-1')
expect(wrapper!.text()).not.toContain('shot-db-1-1')
expect(fetcher.mock.calls).toHaveLength(4)
})
it('查询失败保留已读取镜头并禁用生成,刷新恢复后才允许操作', async () => {
let fail = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
if (fail) return new Response('{"message":"数据库暂不可用"}', { status: 503 })
return new Response(
JSON.stringify({
data: String(url).includes('storyboard-directions')
? directionsResult(fixture.id)
: visualStatesResult(fixture.id)
})
)
})
vi.stubGlobal('fetch', fetcher)
mountStoryboard()
await flushPromises()
fail = true
button('刷新分镜').click()
await flushPromises()
expect(wrapper!.text()).toContain('shot-db-1-1')
expect(wrapper!.text()).toContain('数据库暂不可用')
expect(button('生成本集导演设计').disabled).toBe(true)
fail = false
button('重试查询').click()
await flushPromises()
expect(button('生成本集导演设计').disabled).toBe(false)
})
it('镜头工具按需 GET,切换镜头会废弃旧参考图,未完成设计不能编译规格', async () => {
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ShotTools, {
attachTo: document.body,
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
})
expect(fetcher).not.toHaveBeenCalled()
button('读取参考图').click()
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/references')
await wrapper.setProps({ shot: { ...designedShot('shot-db-1-2'), visualState: null } })
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
finish(
new Response(
'{"data":{"shotId":"shot-db-1-1","references":[],"missing":[{"subjectId":"x","subjectName":"旧镜头人物","reason":"缺图"}]}}'
)
)
await flushPromises()
expect(wrapper.text()).not.toContain('旧镜头人物')
expect(button('读取生成规格').disabled).toBe(true)
})
it('提示词必须确认才 POST,使用正式 Shot ID,返回内容以纯文本显示', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: 'shot-db-1-1',
videoPrompt: '<script>模型内容</script>',
negativePrompt: '模糊',
status: 'prompt_ready'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ShotTools, {
attachTo: document.body,
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
})
expect(fetcher).not.toHaveBeenCalled()
await confirmGeneration('读取/生成提示词')
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/video-prompt')
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({ force: false })
expect(wrapper.text()).toContain('<script>模型内容</script>')
expect(wrapper.find('script').exists()).toBe(false)
})
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
vi.stubGlobal(
'fetch',