feat: 同步镜头首帧与视频生产工作区

This commit is contained in:
GouJ
2026-08-31 15:02:31 +08:00
parent 669f4d9df0
commit aeb3ce2986
16 changed files with 1802 additions and 5 deletions
+132
View File
@@ -0,0 +1,132 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mediaAssetUrl } from '../../lib/assets'
import KeyframeDialog from './components/KeyframeDialog.vue'
import { productionApi } from './api'
import {
isActiveVideo,
issueLabel,
primaryKeyframe,
primaryVideo,
productionStatusLabel,
validOptionalSize
} from './model'
import { keyframeFixture, videoFixture } from './testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从 Reka Portal 中查找精确按钮。 */
function button(label: string): HTMLButtonElement {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
describe('镜头生产数据契约', () => {
it('可选尺寸必须成对留空或填写正整数', () => {
expect(validOptionalSize('', '')).toBe(true)
expect(validOptionalSize(1920, 1080)).toBe(true)
expect(validOptionalSize(1920, '')).toBe(false)
expect(validOptionalSize('', 1080)).toBe(false)
expect(validOptionalSize(0, 1080)).toBe(false)
expect(validOptionalSize(10.5, 1080)).toBe(false)
})
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
for (const status of ['pending', 'queued', 'running'] as const)
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
expect(isActiveVideo(videoFixture())).toBe(false)
})
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown')
})
it('视频地址与图片使用相同的安全协议限制', () => {
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
})
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/readiness'))
return new Response(
JSON.stringify({
data: {
total: 1,
ready: 1,
skipped: 0,
inProgress: 0,
blocked: 0,
missingPrompt: 0,
missingKeyframe: 0,
missingReference: 0,
items: []
}
})
)
return new Response(
JSON.stringify({
data: {
total: 1,
targetCount: 1,
created: 1,
skipped: 0,
readiness: {},
failed: 0,
failures: []
}
})
)
})
vi.stubGlobal('fetch', fetcher)
await productionApi.videoReadiness('project/1', true)
await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true })
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
provider: 'seedance',
concurrency: 3,
force: true
})
})
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
expect(button('确认生成首帧').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]])
await wrapper.setProps({ open: false })
await flushPromises()
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }])
})
})