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 type Headers = Record 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') .replace(/Bearer\s+\S+/gi, 'Bearer ') .slice(0, 2_000) } async function apiRecord(response: { status(): number; json(): Promise }, 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() 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([]) })