|
- import { execFileSync } from 'node:child_process'
- import { createHash } from 'node:crypto'
- import fs from 'node:fs'
- import path from 'node:path'
-
- import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, test } from './fixtures'
- import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
-
- type JsonRecord = Record<string, unknown>
- type Headers = Record<string, string>
-
- interface AvatarSnapshot extends JsonRecord {
- id: string
- name: string
- status: string
- bundleReady: boolean
- engineAuthorized?: boolean | null
- sourceType: string
- gender: string
- viewerUrl: string
- voiceCapabilityId: string
- }
-
- interface VoiceSnapshot extends JsonRecord {
- capabilityId: string
- name: string
- gender: string
- runtimeMode: string
- }
-
- interface PersistentResource {
- avatarId: string
- avatarName: string
- title: string
- projectId: string
- materialFileCode: string
- captureJobId: string
- videoId: string
- videoFileCode: string
- }
-
- interface AvatarOutcome {
- avatarId: string
- avatarName: string
- title: string
- result: 'RETAINED' | 'FAILED_CLEANED' | 'FAILED_CLEANUP_INCOMPLETE'
- projectId?: string
- materialFileCode?: string
- captureJobId?: string
- videoId?: string
- videoFileCode?: string
- ttsCapabilityId?: string
- ttsRuntimeMode?: string
- ttsDurationSeconds?: number
- ttsRequestVerified?: boolean
- captureUploadBytes?: number | null
- captureUploadEvidence?: string
- ffprobe?: JsonRecord
- error?: string
- cleanup?: string[]
- }
-
- const SCRIPT_PATH = path.resolve('../滕王阁序.MD')
- const UPLOAD_MATERIAL_PATH = path.resolve('../ai_person_web/public/brand/dashboard-instructor.webp')
- const SCRIPT_TEXT = fs.readFileSync(SCRIPT_PATH, 'utf8').trim()
- const SCRIPT_SHA256 = createHash('sha256').update(SCRIPT_TEXT, 'utf8').digest('hex')
- const UPLOAD_MATERIAL = fs.readFileSync(UPLOAD_MATERIAL_PATH)
- const TERMINAL_JOB_STATUSES = new Set(['succeeded', 'failed', 'cancelled'])
- const TARGET_AVATAR_IDS = new Set(
- (process.env.E2E_AVATAR_IDS || '')
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean),
- )
-
- const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
- const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
- const textValue = (value: unknown) => String(value ?? '').trim()
- const lower = (value: unknown) => textValue(value).toLowerCase()
-
- function safeError(value: unknown) {
- return (value instanceof Error ? value.message : String(value))
- .replace(/([?&](?:access_token|api_key|signature|token|password)=)[^&\s]+/gi, '$1<redacted>')
- .replace(/Bearer\s+\S+/gi, 'Bearer <redacted>')
- .slice(0, 2_000)
- }
-
- async function apiRecord(response: { status(): number; json(): Promise<unknown> }, expected: number | number[] = 200) {
- const accepted = Array.isArray(expected) ? expected : [expected]
- expect(accepted, `API 返回了非预期 HTTP ${response.status()}`).toContain(response.status())
- if (response.status() === 204) return {}
- return asRecord(envelopeData(await response.json()))
- }
-
- function normalizeAvatar(item: JsonRecord): AvatarSnapshot {
- return {
- ...item,
- id: textValue(item.id),
- name: textValue(item.name),
- status: lower(item.status),
- bundleReady: item.bundleReady === true,
- engineAuthorized: item.engineAuthorized == null ? null : item.engineAuthorized === true,
- sourceType: lower(item.sourceType),
- gender: textValue(item.gender),
- viewerUrl: textValue(item.viewerUrl),
- voiceCapabilityId: textValue(item.voiceCapabilityId || asRecord(item.voice).capabilityId),
- }
- }
-
- async function listAllAvatars(request: APIRequestContext, headers: Headers) {
- const first = await apiRecord(await request.get('/api/v1/avatars', {
- headers,
- params: { page: '1', pageSize: '200' },
- }))
- const items = asRecords(first.items ?? first.records)
- const pages = Math.max(1, Number(first.pages || Math.ceil(Number(first.total || items.length) / 200)))
- for (let page = 2; page <= pages; page += 1) {
- const payload = await apiRecord(await request.get('/api/v1/avatars', {
- headers,
- params: { page: String(page), pageSize: '200' },
- }))
- items.push(...asRecords(payload.items ?? payload.records))
- }
- return items.map(normalizeAvatar)
- }
-
- async function listVoices(request: APIRequestContext, headers: Headers) {
- const payload = await apiRecord(await request.get('/api/v1/tts/voices', { headers }))
- expect(payload.ready, '真实 TTS 目录必须处于 ready').toBe(true)
- return asRecords(payload.items).map((item): VoiceSnapshot => ({
- ...item,
- capabilityId: textValue(item.capabilityId || item.id),
- name: textValue(item.name),
- gender: textValue(item.gender),
- runtimeMode: textValue(item.runtimeMode),
- })).filter((item) => item.capabilityId && item.name)
- }
-
- function normalizedGender(value: string): 'female' | 'male' | 'unknown' {
- const normalized = value.trim().toLowerCase()
- if (normalized === 'female' || normalized.includes('女')) return 'female'
- if (normalized === 'male' || normalized.includes('男')) return 'male'
- return 'unknown'
- }
-
- function matchingGender(avatarGender: string, voiceGender: string) {
- const avatar = normalizedGender(avatarGender)
- return avatar !== 'unknown' && avatar === normalizedGender(voiceGender)
- }
-
- function voiceForAvatar(avatar: AvatarSnapshot, voices: VoiceSnapshot[]) {
- const avatarGender = normalizedGender(avatar.gender)
- const preferredCapabilityId = avatarGender === 'female'
- ? 'builtin-voice-female-clear'
- : avatarGender === 'male'
- ? 'builtin-voice-male-gentle'
- : ''
- return voices.find((voice) => voice.capabilityId === preferredCapabilityId)
- || voices.find((voice) => matchingGender(avatar.gender, voice.gender) && voice.runtimeMode.toUpperCase().includes('VITS'))
- || voices.find((voice) => voice.runtimeMode.toUpperCase().includes('VITS'))
- || voices[0]
- }
-
- function compactAvatarName(avatar: AvatarSnapshot) {
- return avatar.name
- .replace('资深教员', '')
- .replace('教员', '')
- .replace(/·/g, ' · ')
- .replace(/\s+/g, ' ')
- .trim()
- }
-
- function resourceShortName(avatar: AvatarSnapshot, occurrence: number) {
- const stem = avatar.name.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 12)
- || avatar.id.slice(-8)
- return occurrence > 1 ? `${stem}${occurrence}` : stem
- }
-
- async function chooseElSelect(page: Page, label: string, option: string) {
- const combobox = page.getByRole('combobox', { name: label, exact: true })
- await combobox
- .locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]')
- .locator('.el-select__wrapper')
- .click()
- const dropdown = page.locator('.el-select-dropdown:visible').last()
- await expect(dropdown).toBeVisible()
- await dropdown.getByRole('option', { name: option, exact: true }).click()
- await expect(dropdown).toBeHidden()
- }
-
- async function selectAvatar(
- page: Page,
- avatar: AvatarSnapshot,
- initialAvatars: AvatarSnapshot[],
- ) {
- await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click()
- const builtIn = avatar.sourceType === 'built_in'
- await page.getByRole('tab', { name: builtIn ? /本地形象/ : /我的形象/ }).click()
- const group = initialAvatars.filter((item) => (item.sourceType === 'built_in') === builtIn)
- const index = group.findIndex((item) => item.id === avatar.id)
- expect(index, `UI 资源列表中找不到数字人 ${avatar.id}`).toBeGreaterThanOrEqual(0)
- const cards = page.locator('.studio-avatar-list button')
- await expect.poll(() => cards.count(), { timeout: 30_000 }).toBeGreaterThan(index)
- const card = cards.nth(index)
- await expect(card).toBeVisible()
- await expect(card.locator('b')).toHaveText(compactAvatarName(avatar))
- await card.click()
- await expect(card).toHaveClass(/active/)
- expect(await card.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中数字人必须有可见边框').not.toBe('0px')
- await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
- return card
- }
-
- async function projectDetail(request: APIRequestContext, headers: Headers, id: string) {
- const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers })
- return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
- }
-
- async function videoDetail(request: APIRequestContext, headers: Headers, id: string) {
- const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers })
- return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
- }
-
- async function exactListItem(
- request: APIRequestContext,
- headers: Headers,
- endpoint: string,
- title: string,
- ) {
- const payload = await apiRecord(await request.get(endpoint, {
- headers,
- params: { keyword: title, page: '1', pageSize: '100' },
- }))
- return asRecords(payload.items).find((item) => textValue(item.name ?? item.title) === title) || null
- }
-
- async function settleCaptureJob(request: APIRequestContext, headers: Headers, jobId: string, cleanup: string[]) {
- const deadline = Date.now() + 20 * 60_000
- while (Date.now() < deadline) {
- const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
- if (response.status() === 404) return
- const job = await apiRecord(response)
- const status = lower(job.status)
- if (TERMINAL_JOB_STATUSES.has(status)) {
- cleanup.push(`任务 ${jobId} 已进入不可变终态 ${status},保留审计历史`)
- return
- }
- if (job.canCancel === true) {
- const cancelled = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers })
- await apiRecord(cancelled, [200, 409])
- cleanup.push(`已请求取消任务 ${jobId}`)
- }
- await new Promise((resolve) => setTimeout(resolve, 5_000))
- }
- throw new Error(`任务 ${jobId} 在失败清理等待期内仍未进入终态`)
- }
-
- async function deleteVideo(request: APIRequestContext, headers: Headers, id: string, cleanup: string[]) {
- const detail = await videoDetail(request, headers, id)
- if (detail.response.status() === 404) return
- expect(detail.response.status()).toBe(200)
- const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, {
- headers,
- params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
- })
- await apiRecord(removed, [200, 404])
- cleanup.push(`已删除失败迭代成片 ${id}`)
- }
-
- async function deleteProjectAndMaterial(
- request: APIRequestContext,
- headers: Headers,
- id: string,
- knownMaterialFileCode: string,
- cleanup: string[],
- ) {
- const detail = await projectDetail(request, headers, id)
- if (detail.response.status() === 404) return
- expect(detail.response.status()).toBe(200)
- const tracks = asRecords(asRecord(detail.data.timeline).tracks)
- const materialFileCodes = new Set([
- knownMaterialFileCode,
- ...tracks.flatMap((track) => asRecords(track.clips)).map((clip) => textValue(clip.fileId)),
- ].filter(Boolean))
- const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, {
- headers,
- params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
- })
- await apiRecord(removed, [200, 404])
- cleanup.push(`已删除失败迭代工程 ${id}`)
- for (const fileCode of materialFileCodes) {
- const fileRemoved = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
- await apiRecord(fileRemoved, [200, 404])
- cleanup.push(`已删除失败迭代上传素材 ${fileCode}`)
- }
- }
-
- async function cleanupFailedIteration(
- request: APIRequestContext,
- headers: Headers,
- title: string,
- ids: { projectId: string; videoId: string; jobId: string; materialFileCode: string },
- ) {
- const cleanup: string[] = []
- const foundJob = ids.jobId
- ? { id: ids.jobId }
- : await exactListItem(request, headers, '/api/v1/jobs', title)
- if (foundJob) await settleCaptureJob(request, headers, textValue(foundJob.id), cleanup)
- const foundVideo = ids.videoId
- ? { id: ids.videoId }
- : await exactListItem(request, headers, '/api/v1/videos', title)
- if (foundVideo) await deleteVideo(request, headers, textValue(foundVideo.id), cleanup)
- const foundProject = ids.projectId
- ? { id: ids.projectId }
- : await exactListItem(request, headers, '/api/v1/video-projects', title)
- if (foundProject) {
- await deleteProjectAndMaterial(
- request,
- headers,
- textValue(foundProject.id),
- ids.materialFileCode,
- cleanup,
- )
- }
- return cleanup
- }
-
- function probeMp4(filename: string) {
- const output = execFileSync('ffprobe', [
- '-v', 'error',
- '-show_entries', 'format=duration,size,format_name:stream=codec_name,codec_type,width,height',
- '-of', 'json',
- filename,
- ], { encoding: 'utf8', windowsHide: true, maxBuffer: 10 * 1024 * 1024 })
- const probe = asRecord(JSON.parse(output))
- const format = asRecord(probe.format)
- const streams = asRecords(probe.streams)
- const video = streams.find((stream) => stream.codec_type === 'video') || {}
- const audio = streams.find((stream) => stream.codec_type === 'audio') || {}
- expect(textValue(format.format_name)).toMatch(/mp4|mov/)
- expect(Number(format.duration), '滕王阁序成片时长必须证明完整长文本已录制').toBeGreaterThan(60)
- expect(Number(format.size), '归档 MP4 不能是占位文件').toBeGreaterThan(100_000)
- expect(textValue(video.codec_name)).toBe('h264')
- expect(Number(video.width)).toBe(1920)
- expect(Number(video.height)).toBe(1080)
- expect(textValue(audio.codec_name)).toBe('aac')
- return {
- durationSeconds: Number(format.duration),
- sizeBytes: Number(format.size),
- format: textValue(format.format_name),
- videoCodec: textValue(video.codec_name),
- audioCodec: textValue(audio.codec_name),
- width: Number(video.width),
- height: Number(video.height),
- }
- }
-
- test.describe.configure({ mode: 'serial' })
- // 浏览器端 Viewer 录制和最终 H.264/AAC 播放均依赖系统 Chrome 的完整媒体能力。
- test.use({ trace: 'off', video: 'off', channel: 'chrome' })
-
- test('每个可用数字人串行生成并保留一条《滕王阁序》真实成片', async ({ page, request }, testInfo: TestInfo) => {
- test.setTimeout(4 * 60 * 60_000)
- expect(SCRIPT_TEXT.length, '《滕王阁序》测试文本不能为空').toBeGreaterThan(500)
- expect(SCRIPT_TEXT.length, '口播文案必须满足页面 3000 字上限').toBeLessThanOrEqual(3_000)
- expect(Math.ceil(SCRIPT_TEXT.length / 6), '口播文案必须满足单条约 5 分钟上限').toBeLessThanOrEqual(300)
- expect(UPLOAD_MATERIAL.length, '上传素材必须是真实图片而非空占位').toBeGreaterThan(50_000)
-
- const headers = await authHeaders(request)
- const initialAvatars = await listAllAvatars(request, headers)
- const allAvailableAvatars = initialAvatars.filter((avatar) => (
- avatar.status === 'ready'
- && avatar.bundleReady
- && avatar.engineAuthorized !== false
- && Boolean(avatar.viewerUrl)
- ))
- const availableAvatars = TARGET_AVATAR_IDS.size
- ? allAvailableAvatars.filter((avatar) => TARGET_AVATAR_IDS.has(avatar.id))
- : allAvailableAvatars
- const voices = await listVoices(request, headers)
- if (TARGET_AVATAR_IDS.size) {
- expect(
- new Set(availableAvatars.map((avatar) => avatar.id)),
- '目标数字人过滤必须精确命中所有 ID',
- ).toEqual(TARGET_AVATAR_IDS)
- }
- expect(availableAvatars.length, '至少需要一个 READY、已制包且有 Viewer 的可用数字人').toBeGreaterThan(0)
- expect(voices.length, '至少需要一个真实可用 TTS 音色').toBeGreaterThan(0)
-
- const runStamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
- const nameOccurrences = new Map<string, number>()
- const persistent: PersistentResource[] = []
- const outcomes: AvatarOutcome[] = []
- const failures: string[] = []
-
- await attachJson(testInfo, 'available-avatar-snapshot', {
- capturedAt: new Date().toISOString(),
- script: { sha256: SCRIPT_SHA256, characters: SCRIPT_TEXT.length, estimatedSeconds: Math.ceil(SCRIPT_TEXT.length / 6) },
- totalAvatarRecords: initialAvatars.length,
- allAvailableAvatarIds: allAvailableAvatars.map((avatar) => avatar.id),
- allAvailableAvatarNames: allAvailableAvatars.map((avatar) => avatar.name),
- selectedAvatarIds: availableAvatars.map((avatar) => avatar.id),
- selectedAvatarNames: availableAvatars.map((avatar) => avatar.name),
- retentionPolicy: '成功工程、成片、任务及工程引用素材均作为预期持久数据保留;仅失败迭代清理可删除临时资源。',
- })
-
- await page.setViewportSize({ width: 1920, height: 1080 })
- await loginAsAdmin(page)
-
- for (const [index, avatar] of availableAvatars.entries()) {
- const baseShortName = resourceShortName(avatar, 1)
- const occurrence = (nameOccurrences.get(baseShortName) || 0) + 1
- nameOccurrences.set(baseShortName, occurrence)
- const shortName = resourceShortName(avatar, occurrence)
- const title = `滕王阁序验证-${shortName}-${runStamp}`
- const materialName = `滕王阁序素材-${shortName}-${runStamp}.webp`
- const selectedVoice = voiceForAvatar(avatar, voices)
- expect(selectedVoice, `数字人 ${avatar.id} 必须可匹配真实 TTS 音色`).toBeTruthy()
- const avatarGender = normalizedGender(avatar.gender)
- if (avatarGender === 'female') {
- expect(selectedVoice!.capabilityId, '女性数字人必须优选清晰女声').toBe('builtin-voice-female-clear')
- } else if (avatarGender === 'male') {
- expect(selectedVoice!.capabilityId, '男性数字人必须优选温和男声').toBe('builtin-voice-male-gentle')
- }
- expect(title).not.toMatch(/APE2E/i)
-
- const ids = { projectId: '', videoId: '', jobId: '', materialFileCode: '' }
- let videoFileCode = ''
- let ttsResponse: Response | null = null
- let ttsDurationSeconds = 0
- let captureResponse: Response | null = null
- let captureResponseHandler: ((response: Response) => void) | null = null
- let iterationError: unknown
-
- try {
- await page.goto('/videos/create?mode=offline')
- await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
- await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
- await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()
-
- await selectAvatar(page, avatar, initialAvatars)
- const railRadii = await page.locator('.editor-tool-rail').evaluate((element) => {
- const style = getComputedStyle(element)
- return { topLeft: style.borderTopLeftRadius, bottomLeft: style.borderBottomLeftRadius }
- })
- expect(railRadii, '工具 rail 左上、左下必须保持直角').toEqual({ topLeft: '0px', bottomLeft: '0px' })
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-avatar-selected-${shortName}`)
-
- await page.getByLabel('项目名称').fill(title)
- await page.locator('.script-field textarea').fill(SCRIPT_TEXT)
-
- await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
- const backgroundCard = page.locator('.scene-background-card').first()
- await expect(backgroundCard).toBeVisible({ timeout: 30_000 })
- const backgroundCapabilityId = textValue(await backgroundCard.getAttribute('data-capability-id'))
- expect(backgroundCapabilityId, '必须选择真实 SCENE 能力资产作为背景').not.toBe('')
- await expect(backgroundCard.locator('img, video').first()).toHaveAttribute('src', /.+/)
- await backgroundCard.click()
- await expect(backgroundCard).toHaveClass(/active/)
- await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:')
-
- await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
- await page.getByRole('button', { name: /添加正文/ }).click()
- await page.getByLabel('显示文字').fill('滕王阁序')
- await expect(page.locator('.stage-overlay[data-overlay-kind="body"]')).toContainText('滕王阁序')
-
- await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
- await page.getByRole('button', { name: /检查标记/ }).click()
- await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible()
-
- await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
- await page.locator('.media-library input[type="file"]').setInputFiles({
- name: materialName,
- mimeType: 'image/webp',
- buffer: UPLOAD_MATERIAL,
- })
- await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
- await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()
- await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0)
-
- await chooseElSelect(
- page,
- '语音能力资产',
- `${selectedVoice!.name}(${selectedVoice!.runtimeMode.toUpperCase().includes('ZIPVOICE') ? '克隆' : '内置'})`,
- )
- await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
- const selectedVoiceCard = page.locator('.editor-resource-list article.active')
- await expect(selectedVoiceCard).toBeVisible()
- expect(await selectedVoiceCard.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中音色必须有可见边框').not.toBe('0px')
- const voicePreviewIcon = selectedVoiceCard.locator('.offline-voice-preview')
- await expect(voicePreviewIcon).toBeVisible()
- await expect(voicePreviewIcon.locator('svg')).toHaveCount(1)
- expect((await voicePreviewIcon.innerText()).trim(), '音频试听操作只能显示图标').toBe('')
-
- const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true })
- if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') {
- await subtitleSwitch
- .locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]')
- .click()
- }
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-configured-${shortName}`)
-
- await page.getByRole('button', { name: '保存工程', exact: true }).click()
- await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 90_000 })
- await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
- ids.projectId = new URL(page.url()).searchParams.get('project') || ''
- expect(ids.projectId).not.toBe('')
-
- let persistedProject = await projectDetail(request, headers, ids.projectId)
- expect(persistedProject.response.status()).toBe(200)
- expect(persistedProject.data.name).toBe(title)
- expect(persistedProject.data.scriptText).toBe(SCRIPT_TEXT)
- expect(persistedProject.data.avatarId).toBe(avatar.id)
- expect(persistedProject.data.backgroundType).toBe('asset')
- expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId)
- expect(persistedProject.data.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
- expect(persistedProject.data.subtitleEnabled).toBe(true)
- const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
- expect(tracks.map((track) => textValue(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE'])
- const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
- ids.materialFileCode = textValue(mediaClips.find((clip) => clip.type === 'MEDIA')?.fileId)
- expect(ids.materialFileCode, '上传素材必须进入 MEDIA 轨道').not.toBe('')
- const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips)
- expect(elementClips.some((clip) => clip.type === 'BODY' && clip.text === '滕王阁序')).toBe(true)
- expect(elementClips.some((clip) => clip.type === 'CHECK')).toBe(true)
- const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
- expect(audioClip.capabilityId).toBe(selectedVoice!.capabilityId)
- expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips).map((clip) => textValue(clip.text)).join('')).toBe(SCRIPT_TEXT)
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-saved-${shortName}`)
-
- captureResponseHandler = (response: Response) => {
- const requestUrl = new URL(response.url())
- if (response.request().method() !== 'POST') return
- if (requestUrl.pathname === '/api/v1/tts') ttsResponse = response
- if (requestUrl.pathname === '/api/v1/captures') captureResponse = response
- }
- page.on('response', captureResponseHandler)
-
- await page.getByRole('button', { name: '生成视频', exact: true }).click()
- await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 })
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-recording-${shortName}`)
-
- await expect.poll(() => ttsResponse?.status() || 0, {
- message: '页面必须调用真实 /tts',
- timeout: 180_000,
- }).toBe(200)
- const ttsRequestPayload = ttsResponse!.request().postDataJSON() as JsonRecord
- expect(ttsRequestPayload.text).toBe(SCRIPT_TEXT)
- expect(ttsRequestPayload.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
- const speech = asRecord(envelopeData(await ttsResponse!.json()))
- expect(textValue(speech.audioUrl), '真实 TTS 必须返回受保护 WAV 地址').not.toBe('')
- expect(textValue(speech.mimeType)).toBe('audio/wav')
- ttsDurationSeconds = Number(speech.duration)
- expect(ttsDurationSeconds, '真实 TTS 音频必须覆盖长文本').toBeGreaterThan(60)
-
- await expect.poll(() => captureResponse?.status() || 0, {
- message: 'Viewer 浏览器录制必须上传 /captures',
- timeout: 35 * 60_000,
- }).toBe(202)
-
- const succeeded = page.locator('.editor-render-progress.is-succeeded')
- await expect(succeeded).toContainText('视频生成完成', { timeout: 45 * 60_000 })
- await expect(succeeded).toContainText('100%')
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-rendered-${shortName}`)
-
- persistedProject = await projectDetail(request, headers, ids.projectId)
- expect(persistedProject.response.status()).toBe(200)
- ids.jobId = textValue(persistedProject.data.lastJobId)
- const projectVideoId = textValue(persistedProject.data.lastVideoId)
- expect(ids.jobId, 'UI 成功后工程必须公开 lastJobId').not.toBe('')
- expect(projectVideoId, 'UI 成功后工程必须公开 lastVideoId').not.toBe('')
- const job = await apiRecord(await request.get(`/api/v1/jobs/${encodeURIComponent(ids.jobId)}`, { headers }))
- expect(lower(job.status)).toBe('succeeded')
- expect(lower(job.kind)).toBe('capture_transcode')
- expect(job.title).toBe(title)
- expect(Number(job.progress)).toBe(100)
- expect(textValue(asRecord(job.output).mimeType)).toBe('video/mp4')
-
- await succeeded.getByRole('button', { name: '查看作品' }).click()
- await expect(page).toHaveURL(/\/videos\/manage\?video=/)
- ids.videoId = new URL(page.url()).searchParams.get('video') || ''
- expect(ids.videoId).toBe(projectVideoId)
- let detail = page.locator('.video-detail-overlay')
- await expect(detail).toBeVisible({ timeout: 45_000 })
- await expect(detail).toContainText(title)
- await detail.getByRole('button', { name: '关闭' }).click()
-
- await page.getByPlaceholder('搜索标题、简介或标签').fill(title)
- await page.getByRole('button', { name: '搜索', exact: true }).click()
- const card = page.locator('.video-library-card').filter({ hasText: title })
- await expect(card).toBeVisible({ timeout: 30_000 })
- await card.getByRole('button', { name: '查看详情', exact: true }).click()
- detail = page.locator('.video-detail-overlay')
- await expect(detail).toContainText(title)
- const video = detail.locator('video')
- await expect(video).toHaveAttribute('src', /.+/)
- await detail.getByRole('button', { name: '播放视频', exact: true }).click()
- await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true)
- await detail.getByRole('button', { name: '暂停视频', exact: true }).click()
-
- const downloadPromise = page.waitForEvent('download', { timeout: 120_000 })
- await detail.getByRole('button', { name: '下载视频', exact: true }).click()
- const download = await downloadPromise
- const downloadedPath = await download.path()
- expect(downloadedPath, '必须能从成片管理下载正式 MP4').not.toBeNull()
- const ffprobe = probeMp4(downloadedPath!)
-
- const persistedVideo = await videoDetail(request, headers, ids.videoId)
- expect(persistedVideo.response.status()).toBe(200)
- expect(persistedVideo.data.title).toBe(title)
- expect(persistedVideo.data.avatarId).toBe(avatar.id)
- expect(lower(persistedVideo.data.status)).toBe('ready')
- videoFileCode = textValue(persistedVideo.data.videoFileCode)
- expect(videoFileCode).not.toBe('')
- expect(Number(persistedVideo.data.size)).toBeGreaterThan(100_000)
- persistedProject = await projectDetail(request, headers, ids.projectId)
- expect(persistedProject.data.lastJobId).toBe(ids.jobId)
- expect(persistedProject.data.lastVideoId).toBe(ids.videoId)
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-manage-${shortName}`)
-
- await page.goto(`/jobs?jobId=${encodeURIComponent(ids.jobId)}`)
- const jobDialog = page.locator('.el-dialog:visible').last()
- await expect(jobDialog).toBeVisible({ timeout: 30_000 })
- await expect(jobDialog).toContainText(ids.jobId)
- await expect(jobDialog).toContainText('100%')
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-job-${shortName}`)
-
- const retained: PersistentResource = {
- avatarId: avatar.id,
- avatarName: avatar.name,
- title,
- projectId: ids.projectId,
- materialFileCode: ids.materialFileCode,
- captureJobId: ids.jobId,
- videoId: ids.videoId,
- videoFileCode,
- }
- persistent.push(retained)
- outcomes.push({
- ...retained,
- result: 'RETAINED',
- ttsCapabilityId: selectedVoice!.capabilityId,
- ttsRuntimeMode: selectedVoice!.runtimeMode,
- ttsDurationSeconds,
- ttsRequestVerified: true,
- captureUploadBytes: null,
- captureUploadEvidence: 'Browser multipart internals intentionally not inspected; HTTP 202 durable capture job + succeeded FFmpeg job + FFprobe MP4',
- ffprobe,
- })
- await attachJson(testInfo, `expected-persistent-ids-avatar-${String(index + 1).padStart(2, '0')}`, retained)
- console.log(`[TENGWANGGE_RETAINED] avatar=${avatar.name} stage=成片管理已核验 videoId=${ids.videoId}`)
- } catch (error) {
- iterationError = error
- const message = safeError(error)
- failures.push(`${avatar.name}:${message}`)
- await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-failed-${shortName}`).catch(() => undefined)
- const failedOutcome: AvatarOutcome = {
- avatarId: avatar.id,
- avatarName: avatar.name,
- title,
- result: 'FAILED_CLEANED',
- projectId: ids.projectId || undefined,
- materialFileCode: ids.materialFileCode || undefined,
- captureJobId: ids.jobId || undefined,
- videoId: ids.videoId || undefined,
- error: message,
- }
- try {
- failedOutcome.cleanup = await cleanupFailedIteration(request, headers, title, ids)
- } catch (cleanupError) {
- failedOutcome.result = 'FAILED_CLEANUP_INCOMPLETE'
- failedOutcome.cleanup = [safeError(cleanupError)]
- failures.push(`${avatar.name} 清理:${safeError(cleanupError)}`)
- }
- outcomes.push(failedOutcome)
- } finally {
- if (captureResponseHandler) page.off('response', captureResponseHandler)
- // 成功资源是用户要求的正式可见结果;这里刻意不执行任何成功清理。
- if (iterationError) await page.goto('/overview').catch(() => undefined)
- }
- }
-
- await attachJson(testInfo, 'expected-persistent-ids', {
- policy: 'intentional-retention',
- count: persistent.length,
- resources: persistent,
- })
- await attachJson(testInfo, 'all-avatar-video-outcomes', outcomes)
-
- expect(persistent.length, '每个初始可用数字人都必须留下独立工程、任务、素材和成片').toBe(availableAvatars.length)
- expect(new Set(persistent.map((item) => item.projectId)).size).toBe(persistent.length)
- expect(new Set(persistent.map((item) => item.captureJobId)).size).toBe(persistent.length)
- expect(new Set(persistent.map((item) => item.videoId)).size).toBe(persistent.length)
- expect(failures, failures.join('\n')).toEqual([])
- })
|