|
- """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'<article><h3>第 {clip["number"]} 步</h3><p>{html.escape(clip["text"])}</p><video controls preload="none" src="{relative}"></video><p>{clip["duration"]:.2f} 秒 · <a href="{relative}">打开视频</a></p></article>')
- groups.append(f'<section><h2>{html.escape(pack["code"] + " · " + pack["title"])}</h2><div class="grid">{"".join(cards)}</div></section>')
- (REPORT / 'index.html').write_text('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>桥梁讲解 · 教员1 v2</title><style>body{font:16px system-ui;background:#f2f7f6;color:#173d38;margin:24px}header,article{background:white;border:1px solid #cdded9;border-radius:12px;padding:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}video{width:100%;height:360px;background:#111}p{line-height:1.6}a{color:#07856c}h2{margin-top:32px}</style><header><h1>四套桥梁流程 · 教员1 v2 讲解</h1><p>39 段视频已生成到前端 public 目录。点击播放;原语音与上一版视频保留。</p></header>' + ''.join(groups) + '</html>', 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()
|