Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

163 Zeilen
10 KiB

  1. """Render the 39 approved bridge narrations into the web project's public directory.
  2. Run with the digital-human API's Python environment and working directory. Uses
  3. the same capture/encoding as the approved v2 sample, without creating DB assets.
  4. Completed clips are checksum-verified on resume. Original videos/WAVs stay intact.
  5. """
  6. from pathlib import Path
  7. import hashlib
  8. import html
  9. import json
  10. import shutil
  11. import subprocess
  12. import sys
  13. import threading
  14. import requests
  15. ROOT = Path(__file__).resolve().parents[3]
  16. WEB = ROOT / 'unreal_tran/unreal_tran_web'
  17. REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-digital-video-static-20260915'
  18. SAMPLE = REPORT.parent / 'bridge-digital-video-v2-sample-20260915'
  19. sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api'))
  20. from app.config import get_settings
  21. from app.schemas.open_platform import GenerationCommand
  22. from app.services.open_generation_worker import OpenGenerationWorker
  23. def digest(path):
  24. return hashlib.sha256(path.read_bytes()).hexdigest()
  25. def write_json(path, value):
  26. temporary = path.with_suffix(path.suffix + '.tmp')
  27. temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  28. temporary.replace(path)
  29. def probe(settings, path):
  30. result = subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_format',
  31. '-show_streams', '-of', 'json', str(path)], check=True,
  32. capture_output=True, encoding='utf-8')
  33. return json.loads(result.stdout)
  34. def validate(settings, path, source_duration):
  35. info = probe(settings, path)
  36. video = next(s for s in info['streams'] if s['codec_type'] == 'video')
  37. audio = next(s for s in info['streams'] if s['codec_type'] == 'audio')
  38. assert (video['codec_name'], video['width'], video['height'], video['r_frame_rate']) == ('h264', 480, 640, '25/1')
  39. assert audio['codec_name'] == 'aac'
  40. duration = float(info['format']['duration'])
  41. assert abs(duration - source_duration) < .5, (path, duration, source_duration)
  42. subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error',
  43. '-i', str(path), '-f', 'null', '-'], check=True, capture_output=True)
  44. return duration
  45. def main():
  46. REPORT.mkdir(parents=True, exist_ok=True)
  47. settings = get_settings()
  48. voice = json.loads(subprocess.run(['node', '--input-type=module', '-e',
  49. "import m from './src/features/virtual-training-scripts/narration-manifest.js';console.log(JSON.stringify(m))"],
  50. cwd=WEB, check=True, capture_output=True, encoding='utf-8').stdout)
  51. old = json.loads((REPORT.parent / 'bridge-digital-video-20260914/generation.json').read_text('utf-8'))
  52. old_by_key = {clip['key']: clip for clip in old['clips']}
  53. avatar = 'a1000000000000000000000000000005'
  54. http = requests.Session()
  55. http.trust_env = False
  56. response = http.get(str(settings.service_base_url).rstrip('/') + f'/api/v1/avatars/{avatar}/assets/manifest.json', timeout=30)
  57. response.raise_for_status()
  58. bundle = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0]
  59. avatar_hash = hashlib.sha256(response.content).hexdigest()
  60. assert avatar_hash == bundle['bundle_sha256']['manifest.json'], 'Live avatar does not match approved v2'
  61. sample = json.loads((SAMPLE / 'result.json').read_text('utf-8'))
  62. assert sample['providerAvatarId'] == avatar and sample['materialVersion'] == 'v2'
  63. profile = {'avatarName': '教员1', 'materialVersion': 'v2', 'providerAvatarId': avatar,
  64. 'avatarManifestSha256': avatar_hash, 'renderer': 'OpenGenerationWorker.capture',
  65. 'width': 480, 'height': 640, 'fps': 25, 'videoCodec': 'h264', 'audioCodec': 'aac',
  66. 'encoding': 'libx264 veryfast crf20 yuv420p / aac 128k faststart',
  67. 'audioSource': 'existing step narration, unchanged'}
  68. manifest = {'profile': profile, 'scripts': []}
  69. progress_file = REPORT / 'generation.json'
  70. progress = json.loads(progress_file.read_text('utf-8')) if progress_file.exists() else {'profile': profile, 'clips': []}
  71. assert progress['profile'] == profile, 'Generation profile changed; use a separate output report'
  72. done = {item['key']: item for item in progress['clips']}
  73. plan = []
  74. for pack in voice['scripts']:
  75. target_pack = {key: pack[key] for key in ('id', 'version', 'code', 'title')}
  76. target_pack['steps'] = []
  77. manifest['scripts'].append(target_pack)
  78. for clip in pack['steps']:
  79. key = f"{pack['id']}:{pack['version']}:{clip['stepCode']}"
  80. original = old_by_key[key]
  81. assert original['text'] == clip['text'] and original['number'] == clip['number'] and original['code'] == pack['code'], key
  82. source = REPORT.parent.parent / original['previewFile']
  83. source_hash = digest(source)
  84. content_hash = hashlib.sha256(json.dumps([profile, source_hash, clip['text']], sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:16]
  85. url = f"/legacy/virtual-training/videos/narration/{pack['id']}/v{pack['version']}/instructor-1-v2/step-{clip['number']:02}-{content_hash}.mp4"
  86. destination = WEB / 'public' / url.lstrip('/')
  87. audio = WEB / 'public' / clip['url'].lstrip('/')
  88. assert digest(audio) == clip['stepSha256'], f'Original WAV changed: {key}'
  89. plan.append((key, pack, clip, target_pack, source, source_hash, destination, url))
  90. assert len(plan) == 39
  91. worker = OpenGenerationWorker(None, settings, None, None)
  92. work = REPORT / 'capture'
  93. work.mkdir(exist_ok=True)
  94. for index, (key, pack, clip, target_pack, source, source_hash, destination, url) in enumerate(plan, 1):
  95. destination.parent.mkdir(parents=True, exist_ok=True)
  96. source_duration = float(probe(settings, source)['format']['duration'])
  97. entry = done.get(key)
  98. if entry and entry['url'] == url and destination.exists() and digest(destination) == entry['sha256']:
  99. duration = validate(settings, destination, source_duration)
  100. reused = 'resume'
  101. else:
  102. audio = work / 'narration.wav'
  103. captured = work / 'capture.webm'
  104. subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y',
  105. '-i', str(source), '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', str(audio)], check=True)
  106. if pack['code'] == 'TASK-VIRTUAL-005' and clip['number'] == 1:
  107. approved = SAMPLE / 'TASK-VIRTUAL-005-step-01-v2.mp4'
  108. assert sample['originalSha256'] == source_hash and sample['text'] == clip['text']
  109. assert digest(approved) == sample['outputSha256'] and digest(audio) == sample['audioSha256']
  110. shutil.copy2(approved, destination)
  111. reused = 'approved sample'
  112. else:
  113. command = GenerationCommand(title=f"{pack['title']} · 第{clip['number']}步 · 教员1 v2",
  114. text=clip['text'], avatarId='55', voiceId='49', speed=.85, width=480, height=640, subtitles=False)
  115. print(f"[{index}/39] Rendering {pack['code']} step {clip['number']:02}", flush=True)
  116. worker.capture(command, avatar, audio, None, captured, threading.Event())
  117. subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y',
  118. '-i', str(captured), '-r', '25', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
  119. '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(destination)], check=True)
  120. reused = False
  121. duration = validate(settings, destination, source_duration)
  122. entry = {'key': key, 'code': pack['code'], 'number': clip['number'], 'stepCode': clip['stepCode'],
  123. 'text': clip['text'], 'url': url, 'duration': duration, 'sha256': digest(destination),
  124. 'sourceVideoSha256': source_hash, 'audioSha256': digest(audio), 'reused': reused}
  125. done[key] = entry
  126. progress['clips'] = list(done.values())
  127. write_json(progress_file, progress)
  128. assert digest(source) == source_hash, 'Original video changed'
  129. target_pack['steps'].append({name: clip[name] for name in ('stepCode', 'number', 'expectedTitle', 'expectedInstruction', 'text')} |
  130. {name: entry[name] for name in ('url', 'duration', 'sha256')})
  131. print(f"[{index}/39] Verified {pack['code']} step {clip['number']:02}: {duration:.2f}s ({reused or 'rendered'})", flush=True)
  132. target = WEB / 'src/features/virtual-training-scripts/narration-video-manifest.js'
  133. temporary = target.with_suffix('.js.tmp')
  134. 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')
  135. temporary.replace(target)
  136. write_json(REPORT / 'manifest.json', manifest)
  137. groups = []
  138. for pack in manifest['scripts']:
  139. cards = []
  140. for clip in pack['steps']:
  141. relative = '../../..' + '/unreal_tran_web/public' + clip['url']
  142. 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>')
  143. groups.append(f'<section><h2>{html.escape(pack["code"] + " · " + pack["title"])}</h2><div class="grid">{"".join(cards)}</div></section>')
  144. (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')
  145. print(json.dumps({'complete': True, 'clips': len(plan), 'manifest': str(target), 'report': str(REPORT / 'index.html')}, ensure_ascii=False), flush=True)
  146. if __name__ == '__main__':
  147. main()