|
|
|
@@ -0,0 +1,127 @@ |
|
|
|
"""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() |