Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

128 wiersze
8.4 KiB

  1. """Generate an isolated five-step preview using the workshop's avatar and Junhao."""
  2. from pathlib import Path
  3. import hashlib
  4. import html
  5. import json
  6. import shutil
  7. import subprocess
  8. import sys
  9. import threading
  10. import time
  11. import argparse
  12. import httpx
  13. from sqlalchemy import select
  14. ROOT = Path(__file__).resolve().parents[3]
  15. sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api'))
  16. from app.config import get_settings
  17. from app.persistence.database import get_session_factory
  18. from app.persistence.agent_models import Agent, AgentAvatar, AgentVoice
  19. from app.persistence.asset_models import Avatar, CapabilityAsset
  20. from app.schemas.open_platform import GenerationCommand
  21. from app.services.moss_tts_service import MossTtsService
  22. from app.services.open_generation_worker import OpenGenerationWorker
  23. REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-005-moss-junhao-v2-20260919'
  24. WEB = ROOT / 'unreal_tran/unreal_tran_web'
  25. def read_manifest(name):
  26. source = (WEB / 'src/features/virtual-training-scripts' / name).read_text('utf-8')
  27. return json.loads(source.split('export default', 1)[1].strip().rstrip(';'))
  28. def digest(path):
  29. return hashlib.sha256(path.read_bytes()).hexdigest()
  30. def main():
  31. REPORT.mkdir(parents=True, exist_ok=True)
  32. settings = get_settings()
  33. parser = argparse.ArgumentParser()
  34. parser.add_argument('--service-url', required=True, help='Renderer with the validated v2 avatar')
  35. args = parser.parse_args()
  36. settings = settings.model_copy(update={'service_base_url': args.service_url})
  37. with httpx.Client(trust_env=False, timeout=30) as client:
  38. widget = client.get('http://127.0.0.1:6180/api/auth/v1/system-config/public').json()['data']['digitalHumanWidget']
  39. with get_session_factory()() as db:
  40. agent = db.scalar(select(Agent).where(Agent.slug == widget['agentSlug'], Agent.is_delete == 0))
  41. relation = db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id == agent.id).order_by(AgentAvatar.is_default.desc(), AgentAvatar.sort_order))
  42. avatar = db.get(Avatar, relation.avatar_id)
  43. voice = db.scalar(select(CapabilityAsset).where(CapabilityAsset.asset_code == 'builtin-voice-moss-junhao'))
  44. assert db.scalar(select(AgentVoice).where(AgentVoice.agent_id == agent.id, AgentVoice.capability_id == voice.id)), 'Junhao is not selected in the linked agent'
  45. avatar_id, voice_id = str(avatar.id), str(voice.id)
  46. provider_avatar = avatar.provider_avatar_id
  47. profile = {'agent': agent.agent_name, 'slug': agent.slug, 'avatar': avatar.avatar_name,
  48. 'providerAvatarId': provider_avatar, 'voice': voice.asset_name, 'engine': 'MOSS-TTS-Nano',
  49. 'speed': float(agent.voice_speed), 'width': 480, 'height': 640}
  50. manifest = client.get(str(settings.service_base_url).rstrip('/')+f'/api/v1/avatars/{provider_avatar}/assets/manifest.json')
  51. manifest.raise_for_status()
  52. profile['avatarManifestSha256'] = hashlib.sha256(manifest.content).hexdigest()
  53. expected = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0]
  54. assert profile['avatarManifestSha256'] == expected['bundle_sha256']['manifest.json'], 'Renderer does not have the approved v2 avatar'
  55. pack = next(p for p in read_manifest('narration-manifest.js')['scripts'] if p['code'] == 'TASK-VIRTUAL-005')
  56. old_pack = next(p for p in read_manifest('narration-video-manifest.js')['scripts'] if p['code'] == 'TASK-VIRTUAL-005')
  57. renderer = OpenGenerationWorker(None, settings, None, None)
  58. engine = MossTtsService(settings)
  59. rows = []
  60. try:
  61. for step in pack['steps']:
  62. number = step['number']
  63. stem = f'TASK-VIRTUAL-005-step-{number:02}-MOSS-Junhao'
  64. audio, video = REPORT / f'{stem}.wav', REPORT / f'{stem}.mp4'
  65. capture = REPORT / f'{stem}.webm'
  66. if video.exists():
  67. raise RuntimeError('Output exists; use a new report folder to preserve comparisons')
  68. print(f'Step {number}: synthesizing Junhao', flush=True)
  69. previous_audio = REPORT.parent / 'bridge-005-moss-junhao-20260919' / audio.name
  70. if previous_audio.exists():
  71. import wave
  72. shutil.copyfile(previous_audio, audio)
  73. with wave.open(str(audio)) as wav:
  74. generated = {'duration': wav.getnframes() / wav.getframerate()}
  75. else:
  76. generated = engine.synthesize(step['text'], voice_code='Junhao', speed=profile['speed'])
  77. source = engine.output_path(generated['id'])
  78. shutil.copyfile(source, audio)
  79. source.unlink()
  80. print(f'Step {number}: rendering avatar, audio {generated["duration"]:.2f}s', flush=True)
  81. command = GenerationCommand(title=f'005 第{number}步 MOSS Junhao', text=step['text'],
  82. avatarId=avatar_id, voiceId=voice_id, speed=profile['speed'], width=480, height=640, subtitles=False)
  83. renderer.capture(command, provider_avatar, audio, None, capture, threading.Event())
  84. subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y', '-i', str(capture),
  85. '-r', '25', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
  86. '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(video)], check=True)
  87. info = json.loads(subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_streams', '-show_format',
  88. '-of', 'json', str(video)], capture_output=True, text=True, check=True).stdout)
  89. v = next(s for s in info['streams'] if s['codec_type'] == 'video')
  90. a = next(s for s in info['streams'] if s['codec_type'] == 'audio')
  91. assert (v['width'], v['height'], v['codec_name']) == (480, 640, 'h264')
  92. assert a['codec_name'] == 'aac'
  93. assert abs(float(info['format']['duration']) - generated['duration']) < 0.7
  94. subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-i', str(video), '-f', 'null', '-'], check=True)
  95. previous = next(s for s in old_pack['steps'] if s['number'] == number)
  96. old = WEB / 'public' / previous['url'].lstrip('/')
  97. if old.is_file(): shutil.copyfile(old, REPORT / f'original-step-{number:02}.mp4')
  98. row = {'number': number, 'title': step['expectedTitle'], 'text': step['text'], 'video': video.name,
  99. 'audio': audio.name, 'duration': float(info['format']['duration']), 'videoSha256': digest(video)}
  100. rows.append(row)
  101. (REPORT / 'result.json').write_text(json.dumps({'profile': profile, 'steps': rows, 'publishedBindingsChanged': False}, ensure_ascii=False, indent=2), 'utf-8')
  102. print(f'Step {number}: complete ({row["duration"]:.2f}s)', flush=True)
  103. finally:
  104. engine.close()
  105. cards = []
  106. for row in rows:
  107. cards.append(f'''<section><h2>第 {row['number']} 步 · {html.escape(row['title'])}</h2><p>{html.escape(row['text'])}</p>
  108. <div class="compare"><div><h3>新版 · MOSS Junhao</h3><video src="{row['video']}" controls preload="metadata"></video><p><a href="{row['audio']}">单独试听新语音</a></p></div>
  109. <div><h3>原版 · VITS</h3><video src="original-step-{row['number']:02}.mp4" controls preload="metadata"></video></div></div></section>''')
  110. page = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>005 · MOSS Junhao 讲解对比</title>
  111. <style>body{margin:32px auto;max-width:1040px;padding:0 20px;background:#edf5f3;color:#193e38;font:16px/1.7 system-ui}section{background:white;border-radius:16px;padding:24px;margin:24px 0}h2{font-size:20px}.compare{display:flex;gap:28px;flex-wrap:wrap}.compare>div{flex:1;min-width:220px}video{width:100%;max-width:360px;aspect-ratio:3/4;background:#17342f;border-radius:12px}a{color:#008363}</style>
  112. <h1>005 液压泵应急切换 · 新旧讲解对比</h1><p>教员1 · v2素材 · MOSS-TTS-Nano / Junhao · 1.0倍速 · 3:4。五个步骤沿用原讲解文稿,当前为独立预览,线上讲解未替换。</p>''' + ''.join(cards) + '''<script>document.addEventListener('play',e=>{document.querySelectorAll('video,audio').forEach(m=>{if(m!==e.target)m.pause()})},true)</script></html>'''
  113. (REPORT / 'index.html').write_text(page, 'utf-8')
  114. print('Preview ready: '+str(REPORT / 'index.html'), flush=True)
  115. if __name__ == '__main__':
  116. main()