import fs from 'node:fs'; import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; import { login, call } from './role-permissions-live.mjs'; const dir = 'reports/bridge-digital-video-20260914'; const generation = JSON.parse(fs.readFileSync(`${dir}/generation.json`, 'utf8')); assert.equal(generation.clips.length, 39); assert.ok(generation.clips.every(c => c.status === 'SUCCEEDED')); const root = await login('root'), verified = []; for (const code of ['TASK-VIRTUAL-005', 'TASK-VIRTUAL-006', 'TASK-VIRTUAL-007', 'TASK-VIRTUAL-008']) { const binding = JSON.parse(fs.readFileSync(`${dir}/${code}-binding.json`, 'utf8')); const task = (await call(`tran/v1/teaching/assignments/${binding.taskId}`, root.accessToken)).data; for (const clip of generation.clips.filter(c => c.code === code)) { const step = task.definitionSnapshot.steps.find(s => s.stepCode === clip.stepCode); assert.equal(step.audio.narrationText, clip.text); const asset = binding.assets.find(a => a.assetCode === step.audio.narrationVideoAssetCode); assert.ok(asset); const response = await fetch(`http://127.0.0.1:6180${asset.downloadUrl}`, { headers: { Authorization: `Bearer ${root.accessToken}` } }); assert.equal(response.status, 200); const bytes = Buffer.from(await response.arrayBuffer()); const sha = value => crypto.createHash('sha256').update(value).digest('hex'); assert.equal(sha(bytes), asset.sha256); assert.equal(sha(bytes), sha(fs.readFileSync(clip.previewFile))); const probe = JSON.parse(execFileSync('ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', clip.previewFile], { encoding: 'utf8' })); const video = probe.streams.find(s => s.codec_type === 'video'), audio = probe.streams.find(s => s.codec_type === 'audio'); assert.equal(video.codec_name, 'h264'); assert.equal(video.width, 480); assert.equal(video.height, 640); assert.equal(audio.codec_name, 'aac'); assert.ok(Number(probe.format.duration) > 1); verified.push({ code, step: clip.number, assetCode: asset.assetCode, sha256: asset.sha256, duration: Number(probe.format.duration), size: bytes.length }); } console.log('VERIFIED', code); } const summary = { videos: verified.length, duration: verified.reduce((n, v) => n + v.duration, 0), size: verified.reduce((n, v) => n + v.size, 0), preservedWavs: 42, verified }; fs.writeFileSync(`${dir}/verification.json`, JSON.stringify(summary, null, 2)); const escape = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); fs.writeFileSync(`${dir}/index.html`, `四套桥梁训练数字人讲解验收

四套桥梁训练 · 数字人视频讲解

39 段竖版人物视频,合计 ${summary.duration.toFixed(1)} 秒;原 39 段步骤语音和 3 段提醒完整保留。正式任务 86–89 使用发布内容版本的受控 VIDEO 资产。已核对全部视频的步骤讲解词、下载哈希及 H.264/AAC 编码。

讲解面板可切换视频/语音。切步、关闭时停止播放,视频异常自动回退原语音。历史内容版本保留,已有运行、成绩及检查点未修改。

浏览器操作验证 · 全部资源核验 · 正式运行资产/版本保护验证

${['TASK-VIRTUAL-005','TASK-VIRTUAL-006','TASK-VIRTUAL-007','TASK-VIRTUAL-008'].map(code => { const clips = generation.clips.filter(c=>c.code===code); return `

${code} · ${clips.length} 段

${clips.map(c=>``).join('')}
步骤讲解词视频
${c.number}${escape(c.text)}播放
`; }).join('')}
`); console.log('PASS', summary.videos, 'videos', summary.duration.toFixed(1), 'seconds');