|
- """Generate an isolated five-step preview using the workshop's avatar and Junhao."""
- from pathlib import Path
- import hashlib
- import html
- import json
- import shutil
- import subprocess
- import sys
- import threading
- import time
- import argparse
-
- import httpx
- from sqlalchemy import select
-
- ROOT = Path(__file__).resolve().parents[3]
- sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api'))
- from app.config import get_settings
- from app.persistence.database import get_session_factory
- from app.persistence.agent_models import Agent, AgentAvatar, AgentVoice
- from app.persistence.asset_models import Avatar, CapabilityAsset
- from app.schemas.open_platform import GenerationCommand
- from app.services.moss_tts_service import MossTtsService
- from app.services.open_generation_worker import OpenGenerationWorker
-
- REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-005-moss-junhao-v2-20260919'
- WEB = ROOT / 'unreal_tran/unreal_tran_web'
-
-
- def read_manifest(name):
- source = (WEB / 'src/features/virtual-training-scripts' / name).read_text('utf-8')
- return json.loads(source.split('export default', 1)[1].strip().rstrip(';'))
-
-
- def digest(path):
- return hashlib.sha256(path.read_bytes()).hexdigest()
-
-
- def main():
- REPORT.mkdir(parents=True, exist_ok=True)
- settings = get_settings()
- parser = argparse.ArgumentParser()
- parser.add_argument('--service-url', required=True, help='Renderer with the validated v2 avatar')
- args = parser.parse_args()
- settings = settings.model_copy(update={'service_base_url': args.service_url})
- with httpx.Client(trust_env=False, timeout=30) as client:
- widget = client.get('http://127.0.0.1:6180/api/auth/v1/system-config/public').json()['data']['digitalHumanWidget']
- with get_session_factory()() as db:
- agent = db.scalar(select(Agent).where(Agent.slug == widget['agentSlug'], Agent.is_delete == 0))
- relation = db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id == agent.id).order_by(AgentAvatar.is_default.desc(), AgentAvatar.sort_order))
- avatar = db.get(Avatar, relation.avatar_id)
- voice = db.scalar(select(CapabilityAsset).where(CapabilityAsset.asset_code == 'builtin-voice-moss-junhao'))
- assert db.scalar(select(AgentVoice).where(AgentVoice.agent_id == agent.id, AgentVoice.capability_id == voice.id)), 'Junhao is not selected in the linked agent'
- avatar_id, voice_id = str(avatar.id), str(voice.id)
- provider_avatar = avatar.provider_avatar_id
- profile = {'agent': agent.agent_name, 'slug': agent.slug, 'avatar': avatar.avatar_name,
- 'providerAvatarId': provider_avatar, 'voice': voice.asset_name, 'engine': 'MOSS-TTS-Nano',
- 'speed': float(agent.voice_speed), 'width': 480, 'height': 640}
- manifest = client.get(str(settings.service_base_url).rstrip('/')+f'/api/v1/avatars/{provider_avatar}/assets/manifest.json')
- manifest.raise_for_status()
- profile['avatarManifestSha256'] = hashlib.sha256(manifest.content).hexdigest()
- expected = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0]
- assert profile['avatarManifestSha256'] == expected['bundle_sha256']['manifest.json'], 'Renderer does not have the approved v2 avatar'
- pack = next(p for p in read_manifest('narration-manifest.js')['scripts'] if p['code'] == 'TASK-VIRTUAL-005')
- old_pack = next(p for p in read_manifest('narration-video-manifest.js')['scripts'] if p['code'] == 'TASK-VIRTUAL-005')
- renderer = OpenGenerationWorker(None, settings, None, None)
- engine = MossTtsService(settings)
- rows = []
- try:
- for step in pack['steps']:
- number = step['number']
- stem = f'TASK-VIRTUAL-005-step-{number:02}-MOSS-Junhao'
- audio, video = REPORT / f'{stem}.wav', REPORT / f'{stem}.mp4'
- capture = REPORT / f'{stem}.webm'
- if video.exists():
- raise RuntimeError('Output exists; use a new report folder to preserve comparisons')
- print(f'Step {number}: synthesizing Junhao', flush=True)
- previous_audio = REPORT.parent / 'bridge-005-moss-junhao-20260919' / audio.name
- if previous_audio.exists():
- import wave
- shutil.copyfile(previous_audio, audio)
- with wave.open(str(audio)) as wav:
- generated = {'duration': wav.getnframes() / wav.getframerate()}
- else:
- generated = engine.synthesize(step['text'], voice_code='Junhao', speed=profile['speed'])
- source = engine.output_path(generated['id'])
- shutil.copyfile(source, audio)
- source.unlink()
- print(f'Step {number}: rendering avatar, audio {generated["duration"]:.2f}s', flush=True)
- command = GenerationCommand(title=f'005 第{number}步 MOSS Junhao', text=step['text'],
- avatarId=avatar_id, voiceId=voice_id, speed=profile['speed'], width=480, height=640, subtitles=False)
- renderer.capture(command, provider_avatar, audio, None, capture, threading.Event())
- subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-loglevel', 'error', '-y', '-i', str(capture),
- '-r', '25', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
- '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(video)], check=True)
- info = json.loads(subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_streams', '-show_format',
- '-of', 'json', str(video)], capture_output=True, text=True, check=True).stdout)
- v = next(s for s in info['streams'] if s['codec_type'] == 'video')
- a = next(s for s in info['streams'] if s['codec_type'] == 'audio')
- assert (v['width'], v['height'], v['codec_name']) == (480, 640, 'h264')
- assert a['codec_name'] == 'aac'
- assert abs(float(info['format']['duration']) - generated['duration']) < 0.7
- subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-i', str(video), '-f', 'null', '-'], check=True)
- previous = next(s for s in old_pack['steps'] if s['number'] == number)
- old = WEB / 'public' / previous['url'].lstrip('/')
- if old.is_file(): shutil.copyfile(old, REPORT / f'original-step-{number:02}.mp4')
- row = {'number': number, 'title': step['expectedTitle'], 'text': step['text'], 'video': video.name,
- 'audio': audio.name, 'duration': float(info['format']['duration']), 'videoSha256': digest(video)}
- rows.append(row)
- (REPORT / 'result.json').write_text(json.dumps({'profile': profile, 'steps': rows, 'publishedBindingsChanged': False}, ensure_ascii=False, indent=2), 'utf-8')
- print(f'Step {number}: complete ({row["duration"]:.2f}s)', flush=True)
- finally:
- engine.close()
- cards = []
- for row in rows:
- cards.append(f'''<section><h2>第 {row['number']} 步 · {html.escape(row['title'])}</h2><p>{html.escape(row['text'])}</p>
- <div class="compare"><div><h3>新版 · MOSS Junhao</h3><video src="{row['video']}" controls preload="metadata"></video><p><a href="{row['audio']}">单独试听新语音</a></p></div>
- <div><h3>原版 · VITS</h3><video src="original-step-{row['number']:02}.mp4" controls preload="metadata"></video></div></div></section>''')
- page = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>005 · MOSS Junhao 讲解对比</title>
- <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>
- <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>'''
- (REPORT / 'index.html').write_text(page, 'utf-8')
- print('Preview ready: '+str(REPORT / 'index.html'), flush=True)
-
-
- if __name__ == '__main__':
- main()
|