"""Render the 39 approved bridge narrations into the web project's public directory. Run with the digital-human API's Python environment and working directory. Uses the same capture/encoding as the approved v2 sample, without creating DB assets. Completed clips are checksum-verified on resume. Original videos/WAVs stay intact. """ from pathlib import Path import hashlib import html import json import shutil import subprocess import sys import threading import requests ROOT = Path(__file__).resolve().parents[3] WEB = ROOT / 'unreal_tran/unreal_tran_web' REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-digital-video-static-20260915' SAMPLE = REPORT.parent / 'bridge-digital-video-v2-sample-20260915' sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api')) from app.config import get_settings from app.schemas.open_platform import GenerationCommand from app.services.open_generation_worker import OpenGenerationWorker def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def write_json(path, value): temporary = path.with_suffix(path.suffix + '.tmp') temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') temporary.replace(path) def probe(settings, path): result = subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_format', '-show_streams', '-of', 'json', str(path)], check=True, capture_output=True, encoding='utf-8') return json.loads(result.stdout) def validate(settings, path, source_duration): info = probe(settings, path) video = next(s for s in info['streams'] if s['codec_type'] == 'video') audio = next(s for s in info['streams'] if s['codec_type'] == 'audio') assert (video['codec_name'], video['width'], video['height'], video['r_frame_rate']) == ('h264', 480, 640, '25/1') assert audio['codec_name'] == 'aac' duration = float(info['format']['duration']) assert abs(duration - source_duration) < .5, (path, duration, source_duration) subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-i', str(path), '-f', 'null', '-'], check=True, capture_output=True) return duration def main(): REPORT.mkdir(parents=True, exist_ok=True) settings = get_settings() voice = json.loads(subprocess.run(['node', '--input-type=module', '-e', "import m from './src/features/virtual-training-scripts/narration-manifest.js';console.log(JSON.stringify(m))"], cwd=WEB, check=True, capture_output=True, encoding='utf-8').stdout) old = json.loads((REPORT.parent / 'bridge-digital-video-20260914/generation.json').read_text('utf-8')) old_by_key = {clip['key']: clip for clip in old['clips']} avatar = 'a1000000000000000000000000000005' http = requests.Session() http.trust_env = False response = http.get(str(settings.service_base_url).rstrip('/') + f'/api/v1/avatars/{avatar}/assets/manifest.json', timeout=30) response.raise_for_status() bundle = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0] avatar_hash = hashlib.sha256(response.content).hexdigest() assert avatar_hash == bundle['bundle_sha256']['manifest.json'], 'Live avatar does not match approved v2' sample = json.loads((SAMPLE / 'result.json').read_text('utf-8')) assert sample['providerAvatarId'] == avatar and sample['materialVersion'] == 'v2' profile = {'avatarName': '教员1', 'materialVersion': 'v2', 'providerAvatarId': avatar, 'avatarManifestSha256': avatar_hash, 'renderer': 'OpenGenerationWorker.capture', 'width': 480, 'height': 640, 'fps': 25, 'videoCodec': 'h264', 'audioCodec': 'aac', 'encoding': 'libx264 veryfast crf20 yuv420p / aac 128k faststart', 'audioSource': 'existing step narration, unchanged'} manifest = {'profile': profile, 'scripts': []} progress_file = REPORT / 'generation.json' progress = json.loads(progress_file.read_text('utf-8')) if progress_file.exists() else {'profile': profile, 'clips': []} assert progress['profile'] == profile, 'Generation profile changed; use a separate output report' done = {item['key']: item for item in progress['clips']} plan = [] for pack in voice['scripts']: target_pack = {key: pack[key] for key in ('id', 'version', 'code', 'title')} target_pack['steps'] = [] manifest['scripts'].append(target_pack) for clip in pack['steps']: key = f"{pack['id']}:{pack['version']}:{clip['stepCode']}" original = old_by_key[key] assert original['text'] == clip['text'] and original['number'] == clip['number'] and original['code'] == pack['code'], key source = REPORT.parent.parent / original['previewFile'] source_hash = digest(source) content_hash = hashlib.sha256(json.dumps([profile, source_hash, clip['text']], sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:16] url = f"/legacy/virtual-training/videos/narration/{pack['id']}/v{pack['version']}/instructor-1-v2/step-{clip['number']:02}-{content_hash}.mp4" destination = WEB / 'public' / url.lstrip('/') audio = WEB / 'public' / clip['url'].lstrip('/') assert digest(audio) == clip['stepSha256'], f'Original WAV changed: {key}' plan.append((key, pack, clip, target_pack, source, source_hash, destination, url)) assert len(plan) == 39 worker = OpenGenerationWorker(None, settings, None, None) work = REPORT / 'capture' work.mkdir(exist_ok=True) for index, (key, pack, clip, target_pack, source, source_hash, destination, url) in enumerate(plan, 1): destination.parent.mkdir(parents=True, exist_ok=True) source_duration = float(probe(settings, source)['format']['duration']) entry = done.get(key) if entry and entry['url'] == url and destination.exists() and digest(destination) == entry['sha256']: duration = validate(settings, destination, source_duration) reused = 'resume' else: audio = work / 'narration.wav' captured = work / 'capture.webm' subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y', '-i', str(source), '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', str(audio)], check=True) if pack['code'] == 'TASK-VIRTUAL-005' and clip['number'] == 1: approved = SAMPLE / 'TASK-VIRTUAL-005-step-01-v2.mp4' assert sample['originalSha256'] == source_hash and sample['text'] == clip['text'] assert digest(approved) == sample['outputSha256'] and digest(audio) == sample['audioSha256'] shutil.copy2(approved, destination) reused = 'approved sample' else: command = GenerationCommand(title=f"{pack['title']} · 第{clip['number']}步 · 教员1 v2", text=clip['text'], avatarId='55', voiceId='49', speed=.85, width=480, height=640, subtitles=False) print(f"[{index}/39] Rendering {pack['code']} step {clip['number']:02}", flush=True) worker.capture(command, avatar, audio, None, captured, threading.Event()) subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y', '-i', str(captured), '-r', '25', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(destination)], check=True) reused = False duration = validate(settings, destination, source_duration) entry = {'key': key, 'code': pack['code'], 'number': clip['number'], 'stepCode': clip['stepCode'], 'text': clip['text'], 'url': url, 'duration': duration, 'sha256': digest(destination), 'sourceVideoSha256': source_hash, 'audioSha256': digest(audio), 'reused': reused} done[key] = entry progress['clips'] = list(done.values()) write_json(progress_file, progress) assert digest(source) == source_hash, 'Original video changed' target_pack['steps'].append({name: clip[name] for name in ('stepCode', 'number', 'expectedTitle', 'expectedInstruction', 'text')} | {name: entry[name] for name in ('url', 'duration', 'sha256')}) print(f"[{index}/39] Verified {pack['code']} step {clip['number']:02}: {duration:.2f}s ({reused or 'rendered'})", flush=True) target = WEB / 'src/features/virtual-training-scripts/narration-video-manifest.js' temporary = target.with_suffix('.js.tmp') temporary.write_text('// Generated by ute2e/tools/bridge-digital-video-static-generate.py.\nexport default ' + json.dumps(manifest, ensure_ascii=False, indent=2) + ';\n', encoding='utf-8') temporary.replace(target) write_json(REPORT / 'manifest.json', manifest) groups = [] for pack in manifest['scripts']: cards = [] for clip in pack['steps']: relative = '../../..' + '/unreal_tran_web/public' + clip['url'] cards.append(f'

第 {clip["number"]} 步

{html.escape(clip["text"])}

{clip["duration"]:.2f} 秒 · 打开视频

') groups.append(f'

{html.escape(pack["code"] + " · " + pack["title"])}

{"".join(cards)}
') (REPORT / 'index.html').write_text('桥梁讲解 · 教员1 v2

四套桥梁流程 · 教员1 v2 讲解

39 段视频已生成到前端 public 目录。点击播放;原语音与上一版视频保留。

' + ''.join(groups) + '', encoding='utf-8') print(json.dumps({'complete': True, 'clips': len(plan), 'manifest': str(target), 'report': str(REPORT / 'index.html')}, ensure_ascii=False), flush=True) if __name__ == '__main__': main()