Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

698 rindas
33 KiB

  1. import { execFileSync } from 'node:child_process'
  2. import { createHash } from 'node:crypto'
  3. import fs from 'node:fs'
  4. import path from 'node:path'
  5. import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'
  6. import { attachJson, captureScreenshot, expect, test } from './fixtures'
  7. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  8. type JsonRecord = Record<string, unknown>
  9. type Headers = Record<string, string>
  10. interface AvatarSnapshot extends JsonRecord {
  11. id: string
  12. name: string
  13. status: string
  14. bundleReady: boolean
  15. engineAuthorized?: boolean | null
  16. sourceType: string
  17. gender: string
  18. viewerUrl: string
  19. voiceCapabilityId: string
  20. }
  21. interface VoiceSnapshot extends JsonRecord {
  22. capabilityId: string
  23. name: string
  24. gender: string
  25. runtimeMode: string
  26. }
  27. interface PersistentResource {
  28. avatarId: string
  29. avatarName: string
  30. title: string
  31. projectId: string
  32. materialFileCode: string
  33. captureJobId: string
  34. videoId: string
  35. videoFileCode: string
  36. }
  37. interface AvatarOutcome {
  38. avatarId: string
  39. avatarName: string
  40. title: string
  41. result: 'RETAINED' | 'FAILED_CLEANED' | 'FAILED_CLEANUP_INCOMPLETE'
  42. projectId?: string
  43. materialFileCode?: string
  44. captureJobId?: string
  45. videoId?: string
  46. videoFileCode?: string
  47. ttsCapabilityId?: string
  48. ttsRuntimeMode?: string
  49. ttsDurationSeconds?: number
  50. ttsRequestVerified?: boolean
  51. captureUploadBytes?: number | null
  52. captureUploadEvidence?: string
  53. ffprobe?: JsonRecord
  54. error?: string
  55. cleanup?: string[]
  56. }
  57. const SCRIPT_PATH = path.resolve('../滕王阁序.MD')
  58. const UPLOAD_MATERIAL_PATH = path.resolve('../ai_person_web/public/brand/dashboard-instructor.webp')
  59. const SCRIPT_TEXT = fs.readFileSync(SCRIPT_PATH, 'utf8').trim()
  60. const SCRIPT_SHA256 = createHash('sha256').update(SCRIPT_TEXT, 'utf8').digest('hex')
  61. const UPLOAD_MATERIAL = fs.readFileSync(UPLOAD_MATERIAL_PATH)
  62. const TERMINAL_JOB_STATUSES = new Set(['succeeded', 'failed', 'cancelled'])
  63. const TARGET_AVATAR_IDS = new Set(
  64. (process.env.E2E_AVATAR_IDS || '')
  65. .split(',')
  66. .map((item) => item.trim())
  67. .filter(Boolean),
  68. )
  69. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
  70. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  71. const textValue = (value: unknown) => String(value ?? '').trim()
  72. const lower = (value: unknown) => textValue(value).toLowerCase()
  73. function safeError(value: unknown) {
  74. return (value instanceof Error ? value.message : String(value))
  75. .replace(/([?&](?:access_token|api_key|signature|token|password)=)[^&\s]+/gi, '$1<redacted>')
  76. .replace(/Bearer\s+\S+/gi, 'Bearer <redacted>')
  77. .slice(0, 2_000)
  78. }
  79. async function apiRecord(response: { status(): number; json(): Promise<unknown> }, expected: number | number[] = 200) {
  80. const accepted = Array.isArray(expected) ? expected : [expected]
  81. expect(accepted, `API 返回了非预期 HTTP ${response.status()}`).toContain(response.status())
  82. if (response.status() === 204) return {}
  83. return asRecord(envelopeData(await response.json()))
  84. }
  85. function normalizeAvatar(item: JsonRecord): AvatarSnapshot {
  86. return {
  87. ...item,
  88. id: textValue(item.id),
  89. name: textValue(item.name),
  90. status: lower(item.status),
  91. bundleReady: item.bundleReady === true,
  92. engineAuthorized: item.engineAuthorized == null ? null : item.engineAuthorized === true,
  93. sourceType: lower(item.sourceType),
  94. gender: textValue(item.gender),
  95. viewerUrl: textValue(item.viewerUrl),
  96. voiceCapabilityId: textValue(item.voiceCapabilityId || asRecord(item.voice).capabilityId),
  97. }
  98. }
  99. async function listAllAvatars(request: APIRequestContext, headers: Headers) {
  100. const first = await apiRecord(await request.get('/api/v1/avatars', {
  101. headers,
  102. params: { page: '1', pageSize: '200' },
  103. }))
  104. const items = asRecords(first.items ?? first.records)
  105. const pages = Math.max(1, Number(first.pages || Math.ceil(Number(first.total || items.length) / 200)))
  106. for (let page = 2; page <= pages; page += 1) {
  107. const payload = await apiRecord(await request.get('/api/v1/avatars', {
  108. headers,
  109. params: { page: String(page), pageSize: '200' },
  110. }))
  111. items.push(...asRecords(payload.items ?? payload.records))
  112. }
  113. return items.map(normalizeAvatar)
  114. }
  115. async function listVoices(request: APIRequestContext, headers: Headers) {
  116. const payload = await apiRecord(await request.get('/api/v1/tts/voices', { headers }))
  117. expect(payload.ready, '真实 TTS 目录必须处于 ready').toBe(true)
  118. return asRecords(payload.items).map((item): VoiceSnapshot => ({
  119. ...item,
  120. capabilityId: textValue(item.capabilityId || item.id),
  121. name: textValue(item.name),
  122. gender: textValue(item.gender),
  123. runtimeMode: textValue(item.runtimeMode),
  124. })).filter((item) => item.capabilityId && item.name)
  125. }
  126. function normalizedGender(value: string): 'female' | 'male' | 'unknown' {
  127. const normalized = value.trim().toLowerCase()
  128. if (normalized === 'female' || normalized.includes('女')) return 'female'
  129. if (normalized === 'male' || normalized.includes('男')) return 'male'
  130. return 'unknown'
  131. }
  132. function matchingGender(avatarGender: string, voiceGender: string) {
  133. const avatar = normalizedGender(avatarGender)
  134. return avatar !== 'unknown' && avatar === normalizedGender(voiceGender)
  135. }
  136. function voiceForAvatar(avatar: AvatarSnapshot, voices: VoiceSnapshot[]) {
  137. const avatarGender = normalizedGender(avatar.gender)
  138. const preferredCapabilityId = avatarGender === 'female'
  139. ? 'builtin-voice-female-clear'
  140. : avatarGender === 'male'
  141. ? 'builtin-voice-male-gentle'
  142. : ''
  143. return voices.find((voice) => voice.capabilityId === preferredCapabilityId)
  144. || voices.find((voice) => matchingGender(avatar.gender, voice.gender) && voice.runtimeMode.toUpperCase().includes('VITS'))
  145. || voices.find((voice) => voice.runtimeMode.toUpperCase().includes('VITS'))
  146. || voices[0]
  147. }
  148. function compactAvatarName(avatar: AvatarSnapshot) {
  149. return avatar.name
  150. .replace('资深教员', '')
  151. .replace('教员', '')
  152. .replace(/·/g, ' · ')
  153. .replace(/\s+/g, ' ')
  154. .trim()
  155. }
  156. function resourceShortName(avatar: AvatarSnapshot, occurrence: number) {
  157. const stem = avatar.name.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 12)
  158. || avatar.id.slice(-8)
  159. return occurrence > 1 ? `${stem}${occurrence}` : stem
  160. }
  161. async function chooseElSelect(page: Page, label: string, option: string) {
  162. const combobox = page.getByRole('combobox', { name: label, exact: true })
  163. await combobox
  164. .locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]')
  165. .locator('.el-select__wrapper')
  166. .click()
  167. const dropdown = page.locator('.el-select-dropdown:visible').last()
  168. await expect(dropdown).toBeVisible()
  169. await dropdown.getByRole('option', { name: option, exact: true }).click()
  170. await expect(dropdown).toBeHidden()
  171. }
  172. async function selectAvatar(
  173. page: Page,
  174. avatar: AvatarSnapshot,
  175. initialAvatars: AvatarSnapshot[],
  176. ) {
  177. await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click()
  178. const builtIn = avatar.sourceType === 'built_in'
  179. await page.getByRole('tab', { name: builtIn ? /本地形象/ : /我的形象/ }).click()
  180. const group = initialAvatars.filter((item) => (item.sourceType === 'built_in') === builtIn)
  181. const index = group.findIndex((item) => item.id === avatar.id)
  182. expect(index, `UI 资源列表中找不到数字人 ${avatar.id}`).toBeGreaterThanOrEqual(0)
  183. const cards = page.locator('.studio-avatar-list button')
  184. await expect.poll(() => cards.count(), { timeout: 30_000 }).toBeGreaterThan(index)
  185. const card = cards.nth(index)
  186. await expect(card).toBeVisible()
  187. await expect(card.locator('b')).toHaveText(compactAvatarName(avatar))
  188. await card.click()
  189. await expect(card).toHaveClass(/active/)
  190. expect(await card.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中数字人必须有可见边框').not.toBe('0px')
  191. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
  192. return card
  193. }
  194. async function projectDetail(request: APIRequestContext, headers: Headers, id: string) {
  195. const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers })
  196. return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
  197. }
  198. async function videoDetail(request: APIRequestContext, headers: Headers, id: string) {
  199. const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers })
  200. return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
  201. }
  202. async function exactListItem(
  203. request: APIRequestContext,
  204. headers: Headers,
  205. endpoint: string,
  206. title: string,
  207. ) {
  208. const payload = await apiRecord(await request.get(endpoint, {
  209. headers,
  210. params: { keyword: title, page: '1', pageSize: '100' },
  211. }))
  212. return asRecords(payload.items).find((item) => textValue(item.name ?? item.title) === title) || null
  213. }
  214. async function settleCaptureJob(request: APIRequestContext, headers: Headers, jobId: string, cleanup: string[]) {
  215. const deadline = Date.now() + 20 * 60_000
  216. while (Date.now() < deadline) {
  217. const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  218. if (response.status() === 404) return
  219. const job = await apiRecord(response)
  220. const status = lower(job.status)
  221. if (TERMINAL_JOB_STATUSES.has(status)) {
  222. cleanup.push(`任务 ${jobId} 已进入不可变终态 ${status},保留审计历史`)
  223. return
  224. }
  225. if (job.canCancel === true) {
  226. const cancelled = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers })
  227. await apiRecord(cancelled, [200, 409])
  228. cleanup.push(`已请求取消任务 ${jobId}`)
  229. }
  230. await new Promise((resolve) => setTimeout(resolve, 5_000))
  231. }
  232. throw new Error(`任务 ${jobId} 在失败清理等待期内仍未进入终态`)
  233. }
  234. async function deleteVideo(request: APIRequestContext, headers: Headers, id: string, cleanup: string[]) {
  235. const detail = await videoDetail(request, headers, id)
  236. if (detail.response.status() === 404) return
  237. expect(detail.response.status()).toBe(200)
  238. const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, {
  239. headers,
  240. params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
  241. })
  242. await apiRecord(removed, [200, 404])
  243. cleanup.push(`已删除失败迭代成片 ${id}`)
  244. }
  245. async function deleteProjectAndMaterial(
  246. request: APIRequestContext,
  247. headers: Headers,
  248. id: string,
  249. knownMaterialFileCode: string,
  250. cleanup: string[],
  251. ) {
  252. const detail = await projectDetail(request, headers, id)
  253. if (detail.response.status() === 404) return
  254. expect(detail.response.status()).toBe(200)
  255. const tracks = asRecords(asRecord(detail.data.timeline).tracks)
  256. const materialFileCodes = new Set([
  257. knownMaterialFileCode,
  258. ...tracks.flatMap((track) => asRecords(track.clips)).map((clip) => textValue(clip.fileId)),
  259. ].filter(Boolean))
  260. const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, {
  261. headers,
  262. params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
  263. })
  264. await apiRecord(removed, [200, 404])
  265. cleanup.push(`已删除失败迭代工程 ${id}`)
  266. for (const fileCode of materialFileCodes) {
  267. const fileRemoved = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
  268. await apiRecord(fileRemoved, [200, 404])
  269. cleanup.push(`已删除失败迭代上传素材 ${fileCode}`)
  270. }
  271. }
  272. async function cleanupFailedIteration(
  273. request: APIRequestContext,
  274. headers: Headers,
  275. title: string,
  276. ids: { projectId: string; videoId: string; jobId: string; materialFileCode: string },
  277. ) {
  278. const cleanup: string[] = []
  279. const foundJob = ids.jobId
  280. ? { id: ids.jobId }
  281. : await exactListItem(request, headers, '/api/v1/jobs', title)
  282. if (foundJob) await settleCaptureJob(request, headers, textValue(foundJob.id), cleanup)
  283. const foundVideo = ids.videoId
  284. ? { id: ids.videoId }
  285. : await exactListItem(request, headers, '/api/v1/videos', title)
  286. if (foundVideo) await deleteVideo(request, headers, textValue(foundVideo.id), cleanup)
  287. const foundProject = ids.projectId
  288. ? { id: ids.projectId }
  289. : await exactListItem(request, headers, '/api/v1/video-projects', title)
  290. if (foundProject) {
  291. await deleteProjectAndMaterial(
  292. request,
  293. headers,
  294. textValue(foundProject.id),
  295. ids.materialFileCode,
  296. cleanup,
  297. )
  298. }
  299. return cleanup
  300. }
  301. function probeMp4(filename: string) {
  302. const output = execFileSync('ffprobe', [
  303. '-v', 'error',
  304. '-show_entries', 'format=duration,size,format_name:stream=codec_name,codec_type,width,height',
  305. '-of', 'json',
  306. filename,
  307. ], { encoding: 'utf8', windowsHide: true, maxBuffer: 10 * 1024 * 1024 })
  308. const probe = asRecord(JSON.parse(output))
  309. const format = asRecord(probe.format)
  310. const streams = asRecords(probe.streams)
  311. const video = streams.find((stream) => stream.codec_type === 'video') || {}
  312. const audio = streams.find((stream) => stream.codec_type === 'audio') || {}
  313. expect(textValue(format.format_name)).toMatch(/mp4|mov/)
  314. expect(Number(format.duration), '滕王阁序成片时长必须证明完整长文本已录制').toBeGreaterThan(60)
  315. expect(Number(format.size), '归档 MP4 不能是占位文件').toBeGreaterThan(100_000)
  316. expect(textValue(video.codec_name)).toBe('h264')
  317. expect(Number(video.width)).toBe(1920)
  318. expect(Number(video.height)).toBe(1080)
  319. expect(textValue(audio.codec_name)).toBe('aac')
  320. return {
  321. durationSeconds: Number(format.duration),
  322. sizeBytes: Number(format.size),
  323. format: textValue(format.format_name),
  324. videoCodec: textValue(video.codec_name),
  325. audioCodec: textValue(audio.codec_name),
  326. width: Number(video.width),
  327. height: Number(video.height),
  328. }
  329. }
  330. test.describe.configure({ mode: 'serial' })
  331. // 浏览器端 Viewer 录制和最终 H.264/AAC 播放均依赖系统 Chrome 的完整媒体能力。
  332. test.use({ trace: 'off', video: 'off', channel: 'chrome' })
  333. test('每个可用数字人串行生成并保留一条《滕王阁序》真实成片', async ({ page, request }, testInfo: TestInfo) => {
  334. test.setTimeout(4 * 60 * 60_000)
  335. expect(SCRIPT_TEXT.length, '《滕王阁序》测试文本不能为空').toBeGreaterThan(500)
  336. expect(SCRIPT_TEXT.length, '口播文案必须满足页面 3000 字上限').toBeLessThanOrEqual(3_000)
  337. expect(Math.ceil(SCRIPT_TEXT.length / 6), '口播文案必须满足单条约 5 分钟上限').toBeLessThanOrEqual(300)
  338. expect(UPLOAD_MATERIAL.length, '上传素材必须是真实图片而非空占位').toBeGreaterThan(50_000)
  339. const headers = await authHeaders(request)
  340. const initialAvatars = await listAllAvatars(request, headers)
  341. const allAvailableAvatars = initialAvatars.filter((avatar) => (
  342. avatar.status === 'ready'
  343. && avatar.bundleReady
  344. && avatar.engineAuthorized !== false
  345. && Boolean(avatar.viewerUrl)
  346. ))
  347. const availableAvatars = TARGET_AVATAR_IDS.size
  348. ? allAvailableAvatars.filter((avatar) => TARGET_AVATAR_IDS.has(avatar.id))
  349. : allAvailableAvatars
  350. const voices = await listVoices(request, headers)
  351. if (TARGET_AVATAR_IDS.size) {
  352. expect(
  353. new Set(availableAvatars.map((avatar) => avatar.id)),
  354. '目标数字人过滤必须精确命中所有 ID',
  355. ).toEqual(TARGET_AVATAR_IDS)
  356. }
  357. expect(availableAvatars.length, '至少需要一个 READY、已制包且有 Viewer 的可用数字人').toBeGreaterThan(0)
  358. expect(voices.length, '至少需要一个真实可用 TTS 音色').toBeGreaterThan(0)
  359. const runStamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
  360. const nameOccurrences = new Map<string, number>()
  361. const persistent: PersistentResource[] = []
  362. const outcomes: AvatarOutcome[] = []
  363. const failures: string[] = []
  364. await attachJson(testInfo, 'available-avatar-snapshot', {
  365. capturedAt: new Date().toISOString(),
  366. script: { sha256: SCRIPT_SHA256, characters: SCRIPT_TEXT.length, estimatedSeconds: Math.ceil(SCRIPT_TEXT.length / 6) },
  367. totalAvatarRecords: initialAvatars.length,
  368. allAvailableAvatarIds: allAvailableAvatars.map((avatar) => avatar.id),
  369. allAvailableAvatarNames: allAvailableAvatars.map((avatar) => avatar.name),
  370. selectedAvatarIds: availableAvatars.map((avatar) => avatar.id),
  371. selectedAvatarNames: availableAvatars.map((avatar) => avatar.name),
  372. retentionPolicy: '成功工程、成片、任务及工程引用素材均作为预期持久数据保留;仅失败迭代清理可删除临时资源。',
  373. })
  374. await page.setViewportSize({ width: 1920, height: 1080 })
  375. await loginAsAdmin(page)
  376. for (const [index, avatar] of availableAvatars.entries()) {
  377. const baseShortName = resourceShortName(avatar, 1)
  378. const occurrence = (nameOccurrences.get(baseShortName) || 0) + 1
  379. nameOccurrences.set(baseShortName, occurrence)
  380. const shortName = resourceShortName(avatar, occurrence)
  381. const title = `滕王阁序验证-${shortName}-${runStamp}`
  382. const materialName = `滕王阁序素材-${shortName}-${runStamp}.webp`
  383. const selectedVoice = voiceForAvatar(avatar, voices)
  384. expect(selectedVoice, `数字人 ${avatar.id} 必须可匹配真实 TTS 音色`).toBeTruthy()
  385. const avatarGender = normalizedGender(avatar.gender)
  386. if (avatarGender === 'female') {
  387. expect(selectedVoice!.capabilityId, '女性数字人必须优选清晰女声').toBe('builtin-voice-female-clear')
  388. } else if (avatarGender === 'male') {
  389. expect(selectedVoice!.capabilityId, '男性数字人必须优选温和男声').toBe('builtin-voice-male-gentle')
  390. }
  391. expect(title).not.toMatch(/APE2E/i)
  392. const ids = { projectId: '', videoId: '', jobId: '', materialFileCode: '' }
  393. let videoFileCode = ''
  394. let ttsResponse: Response | null = null
  395. let ttsDurationSeconds = 0
  396. let captureResponse: Response | null = null
  397. let captureResponseHandler: ((response: Response) => void) | null = null
  398. let iterationError: unknown
  399. try {
  400. await page.goto('/videos/create?mode=offline')
  401. await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
  402. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
  403. await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()
  404. await selectAvatar(page, avatar, initialAvatars)
  405. const railRadii = await page.locator('.editor-tool-rail').evaluate((element) => {
  406. const style = getComputedStyle(element)
  407. return { topLeft: style.borderTopLeftRadius, bottomLeft: style.borderBottomLeftRadius }
  408. })
  409. expect(railRadii, '工具 rail 左上、左下必须保持直角').toEqual({ topLeft: '0px', bottomLeft: '0px' })
  410. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-avatar-selected-${shortName}`)
  411. await page.getByLabel('项目名称').fill(title)
  412. await page.locator('.script-field textarea').fill(SCRIPT_TEXT)
  413. await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
  414. const backgroundCard = page.locator('.scene-background-card').first()
  415. await expect(backgroundCard).toBeVisible({ timeout: 30_000 })
  416. const backgroundCapabilityId = textValue(await backgroundCard.getAttribute('data-capability-id'))
  417. expect(backgroundCapabilityId, '必须选择真实 SCENE 能力资产作为背景').not.toBe('')
  418. await expect(backgroundCard.locator('img, video').first()).toHaveAttribute('src', /.+/)
  419. await backgroundCard.click()
  420. await expect(backgroundCard).toHaveClass(/active/)
  421. await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:')
  422. await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
  423. await page.getByRole('button', { name: /添加正文/ }).click()
  424. await page.getByLabel('显示文字').fill('滕王阁序')
  425. await expect(page.locator('.stage-overlay[data-overlay-kind="body"]')).toContainText('滕王阁序')
  426. await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
  427. await page.getByRole('button', { name: /检查标记/ }).click()
  428. await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible()
  429. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  430. await page.locator('.media-library input[type="file"]').setInputFiles({
  431. name: materialName,
  432. mimeType: 'image/webp',
  433. buffer: UPLOAD_MATERIAL,
  434. })
  435. await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
  436. await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()
  437. await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0)
  438. await chooseElSelect(
  439. page,
  440. '语音能力资产',
  441. `${selectedVoice!.name}(${selectedVoice!.runtimeMode.toUpperCase().includes('ZIPVOICE') ? '克隆' : '内置'})`,
  442. )
  443. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  444. const selectedVoiceCard = page.locator('.editor-resource-list article.active')
  445. await expect(selectedVoiceCard).toBeVisible()
  446. expect(await selectedVoiceCard.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中音色必须有可见边框').not.toBe('0px')
  447. const voicePreviewIcon = selectedVoiceCard.locator('.offline-voice-preview')
  448. await expect(voicePreviewIcon).toBeVisible()
  449. await expect(voicePreviewIcon.locator('svg')).toHaveCount(1)
  450. expect((await voicePreviewIcon.innerText()).trim(), '音频试听操作只能显示图标').toBe('')
  451. const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true })
  452. if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') {
  453. await subtitleSwitch
  454. .locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]')
  455. .click()
  456. }
  457. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-configured-${shortName}`)
  458. await page.getByRole('button', { name: '保存工程', exact: true }).click()
  459. await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 90_000 })
  460. await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
  461. ids.projectId = new URL(page.url()).searchParams.get('project') || ''
  462. expect(ids.projectId).not.toBe('')
  463. let persistedProject = await projectDetail(request, headers, ids.projectId)
  464. expect(persistedProject.response.status()).toBe(200)
  465. expect(persistedProject.data.name).toBe(title)
  466. expect(persistedProject.data.scriptText).toBe(SCRIPT_TEXT)
  467. expect(persistedProject.data.avatarId).toBe(avatar.id)
  468. expect(persistedProject.data.backgroundType).toBe('asset')
  469. expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId)
  470. expect(persistedProject.data.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
  471. expect(persistedProject.data.subtitleEnabled).toBe(true)
  472. const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
  473. expect(tracks.map((track) => textValue(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE'])
  474. const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
  475. ids.materialFileCode = textValue(mediaClips.find((clip) => clip.type === 'MEDIA')?.fileId)
  476. expect(ids.materialFileCode, '上传素材必须进入 MEDIA 轨道').not.toBe('')
  477. const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips)
  478. expect(elementClips.some((clip) => clip.type === 'BODY' && clip.text === '滕王阁序')).toBe(true)
  479. expect(elementClips.some((clip) => clip.type === 'CHECK')).toBe(true)
  480. const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
  481. expect(audioClip.capabilityId).toBe(selectedVoice!.capabilityId)
  482. expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips).map((clip) => textValue(clip.text)).join('')).toBe(SCRIPT_TEXT)
  483. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-saved-${shortName}`)
  484. captureResponseHandler = (response: Response) => {
  485. const requestUrl = new URL(response.url())
  486. if (response.request().method() !== 'POST') return
  487. if (requestUrl.pathname === '/api/v1/tts') ttsResponse = response
  488. if (requestUrl.pathname === '/api/v1/captures') captureResponse = response
  489. }
  490. page.on('response', captureResponseHandler)
  491. await page.getByRole('button', { name: '生成视频', exact: true }).click()
  492. await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 })
  493. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-recording-${shortName}`)
  494. await expect.poll(() => ttsResponse?.status() || 0, {
  495. message: '页面必须调用真实 /tts',
  496. timeout: 180_000,
  497. }).toBe(200)
  498. const ttsRequestPayload = ttsResponse!.request().postDataJSON() as JsonRecord
  499. expect(ttsRequestPayload.text).toBe(SCRIPT_TEXT)
  500. expect(ttsRequestPayload.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
  501. const speech = asRecord(envelopeData(await ttsResponse!.json()))
  502. expect(textValue(speech.audioUrl), '真实 TTS 必须返回受保护 WAV 地址').not.toBe('')
  503. expect(textValue(speech.mimeType)).toBe('audio/wav')
  504. ttsDurationSeconds = Number(speech.duration)
  505. expect(ttsDurationSeconds, '真实 TTS 音频必须覆盖长文本').toBeGreaterThan(60)
  506. await expect.poll(() => captureResponse?.status() || 0, {
  507. message: 'Viewer 浏览器录制必须上传 /captures',
  508. timeout: 35 * 60_000,
  509. }).toBe(202)
  510. const succeeded = page.locator('.editor-render-progress.is-succeeded')
  511. await expect(succeeded).toContainText('视频生成完成', { timeout: 45 * 60_000 })
  512. await expect(succeeded).toContainText('100%')
  513. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-rendered-${shortName}`)
  514. persistedProject = await projectDetail(request, headers, ids.projectId)
  515. expect(persistedProject.response.status()).toBe(200)
  516. ids.jobId = textValue(persistedProject.data.lastJobId)
  517. const projectVideoId = textValue(persistedProject.data.lastVideoId)
  518. expect(ids.jobId, 'UI 成功后工程必须公开 lastJobId').not.toBe('')
  519. expect(projectVideoId, 'UI 成功后工程必须公开 lastVideoId').not.toBe('')
  520. const job = await apiRecord(await request.get(`/api/v1/jobs/${encodeURIComponent(ids.jobId)}`, { headers }))
  521. expect(lower(job.status)).toBe('succeeded')
  522. expect(lower(job.kind)).toBe('capture_transcode')
  523. expect(job.title).toBe(title)
  524. expect(Number(job.progress)).toBe(100)
  525. expect(textValue(asRecord(job.output).mimeType)).toBe('video/mp4')
  526. await succeeded.getByRole('button', { name: '查看作品' }).click()
  527. await expect(page).toHaveURL(/\/videos\/manage\?video=/)
  528. ids.videoId = new URL(page.url()).searchParams.get('video') || ''
  529. expect(ids.videoId).toBe(projectVideoId)
  530. let detail = page.locator('.video-detail-overlay')
  531. await expect(detail).toBeVisible({ timeout: 45_000 })
  532. await expect(detail).toContainText(title)
  533. await detail.getByRole('button', { name: '关闭' }).click()
  534. await page.getByPlaceholder('搜索标题、简介或标签').fill(title)
  535. await page.getByRole('button', { name: '搜索', exact: true }).click()
  536. const card = page.locator('.video-library-card').filter({ hasText: title })
  537. await expect(card).toBeVisible({ timeout: 30_000 })
  538. await card.getByRole('button', { name: '查看详情', exact: true }).click()
  539. detail = page.locator('.video-detail-overlay')
  540. await expect(detail).toContainText(title)
  541. const video = detail.locator('video')
  542. await expect(video).toHaveAttribute('src', /.+/)
  543. await detail.getByRole('button', { name: '播放视频', exact: true }).click()
  544. await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true)
  545. await detail.getByRole('button', { name: '暂停视频', exact: true }).click()
  546. const downloadPromise = page.waitForEvent('download', { timeout: 120_000 })
  547. await detail.getByRole('button', { name: '下载视频', exact: true }).click()
  548. const download = await downloadPromise
  549. const downloadedPath = await download.path()
  550. expect(downloadedPath, '必须能从成片管理下载正式 MP4').not.toBeNull()
  551. const ffprobe = probeMp4(downloadedPath!)
  552. const persistedVideo = await videoDetail(request, headers, ids.videoId)
  553. expect(persistedVideo.response.status()).toBe(200)
  554. expect(persistedVideo.data.title).toBe(title)
  555. expect(persistedVideo.data.avatarId).toBe(avatar.id)
  556. expect(lower(persistedVideo.data.status)).toBe('ready')
  557. videoFileCode = textValue(persistedVideo.data.videoFileCode)
  558. expect(videoFileCode).not.toBe('')
  559. expect(Number(persistedVideo.data.size)).toBeGreaterThan(100_000)
  560. persistedProject = await projectDetail(request, headers, ids.projectId)
  561. expect(persistedProject.data.lastJobId).toBe(ids.jobId)
  562. expect(persistedProject.data.lastVideoId).toBe(ids.videoId)
  563. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-manage-${shortName}`)
  564. await page.goto(`/jobs?jobId=${encodeURIComponent(ids.jobId)}`)
  565. const jobDialog = page.locator('.el-dialog:visible').last()
  566. await expect(jobDialog).toBeVisible({ timeout: 30_000 })
  567. await expect(jobDialog).toContainText(ids.jobId)
  568. await expect(jobDialog).toContainText('100%')
  569. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-job-${shortName}`)
  570. const retained: PersistentResource = {
  571. avatarId: avatar.id,
  572. avatarName: avatar.name,
  573. title,
  574. projectId: ids.projectId,
  575. materialFileCode: ids.materialFileCode,
  576. captureJobId: ids.jobId,
  577. videoId: ids.videoId,
  578. videoFileCode,
  579. }
  580. persistent.push(retained)
  581. outcomes.push({
  582. ...retained,
  583. result: 'RETAINED',
  584. ttsCapabilityId: selectedVoice!.capabilityId,
  585. ttsRuntimeMode: selectedVoice!.runtimeMode,
  586. ttsDurationSeconds,
  587. ttsRequestVerified: true,
  588. captureUploadBytes: null,
  589. captureUploadEvidence: 'Browser multipart internals intentionally not inspected; HTTP 202 durable capture job + succeeded FFmpeg job + FFprobe MP4',
  590. ffprobe,
  591. })
  592. await attachJson(testInfo, `expected-persistent-ids-avatar-${String(index + 1).padStart(2, '0')}`, retained)
  593. console.log(`[TENGWANGGE_RETAINED] avatar=${avatar.name} stage=成片管理已核验 videoId=${ids.videoId}`)
  594. } catch (error) {
  595. iterationError = error
  596. const message = safeError(error)
  597. failures.push(`${avatar.name}:${message}`)
  598. await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-failed-${shortName}`).catch(() => undefined)
  599. const failedOutcome: AvatarOutcome = {
  600. avatarId: avatar.id,
  601. avatarName: avatar.name,
  602. title,
  603. result: 'FAILED_CLEANED',
  604. projectId: ids.projectId || undefined,
  605. materialFileCode: ids.materialFileCode || undefined,
  606. captureJobId: ids.jobId || undefined,
  607. videoId: ids.videoId || undefined,
  608. error: message,
  609. }
  610. try {
  611. failedOutcome.cleanup = await cleanupFailedIteration(request, headers, title, ids)
  612. } catch (cleanupError) {
  613. failedOutcome.result = 'FAILED_CLEANUP_INCOMPLETE'
  614. failedOutcome.cleanup = [safeError(cleanupError)]
  615. failures.push(`${avatar.name} 清理:${safeError(cleanupError)}`)
  616. }
  617. outcomes.push(failedOutcome)
  618. } finally {
  619. if (captureResponseHandler) page.off('response', captureResponseHandler)
  620. // 成功资源是用户要求的正式可见结果;这里刻意不执行任何成功清理。
  621. if (iterationError) await page.goto('/overview').catch(() => undefined)
  622. }
  623. }
  624. await attachJson(testInfo, 'expected-persistent-ids', {
  625. policy: 'intentional-retention',
  626. count: persistent.length,
  627. resources: persistent,
  628. })
  629. await attachJson(testInfo, 'all-avatar-video-outcomes', outcomes)
  630. expect(persistent.length, '每个初始可用数字人都必须留下独立工程、任务、素材和成片').toBe(availableAvatars.length)
  631. expect(new Set(persistent.map((item) => item.projectId)).size).toBe(persistent.length)
  632. expect(new Set(persistent.map((item) => item.captureJobId)).size).toBe(persistent.length)
  633. expect(new Set(persistent.map((item) => item.videoId)).size).toBe(persistent.length)
  634. expect(failures, failures.join('\n')).toEqual([])
  635. })