|
- """Generate a resumable, versioned narration set; never replace old media.
-
- Run from ai_person_api with its configured Python environment. Reads the linked
- agent's actual default voice and validates the approved avatar bundle. Manifest
- activation is deliberately separate, after all 39 clips have been verified.
- """
- from pathlib import Path
- import copy
- import hashlib
- import html
- import json
- import re
- import shutil
- import subprocess
- import sys
- import threading
-
- import httpx
- import numpy as np
- import soundfile as sf
- from sqlalchemy import select
-
- ROOT = Path(__file__).resolve().parents[3]
- WEB = ROOT / 'unreal_tran/unreal_tran_web'
- REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-narration-junhao-20260921'
- SOURCE = WEB / 'src/features/virtual-training-scripts'
- REVISION = 'junhao-v2-20260921'
- 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
-
-
- 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', 'utf-8')
- temporary.replace(path)
-
-
- def manifest(name):
- return json.loads((SOURCE / name).read_text('utf-8').split('export default', 1)[1].strip().rstrip(';'))
-
-
- def probe(settings, path):
- return json.loads(subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_streams',
- '-show_format', '-of', 'json', str(path)], capture_output=True, encoding='utf-8', check=True).stdout)
-
-
- def prepare_audio(settings, raw, audio):
- samples, rate = sf.read(raw, always_2d=True)
- result = subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-i', str(raw), '-af',
- 'silencedetect=noise=-48dB:d=0.65', '-f', 'null', '-'], capture_output=True, encoding='utf-8', check=True)
- starts = [float(x) for x in re.findall(r'silence_start: ([0-9.]+)', result.stderr)]
- ends = [float(x) for x in re.findall(r'silence_end: ([0-9.]+)', result.stderr)]
- if len(starts) > len(ends): ends.append(len(samples) / rate)
- pieces, cuts, last = [], [], 0
- for start, end in zip(starts, ends):
- a, b = round((start + .16) * rate), round((end - .16) * rate)
- if b <= a: continue
- pieces.append(samples[last:a]); last = b
- cuts.append({'start': start, 'end': end, 'keptSeconds': .32})
- pieces.append(samples[last:])
- prepared = np.concatenate(pieces)
- sf.write(audio, prepared, rate, subtype='PCM_16')
- return {'duration': len(prepared) / rate, 'rawDuration': len(samples) / rate, 'pauseCuts': cuts}
-
-
- def main():
- REPORT.mkdir(parents=True, exist_ok=True)
- settings = get_settings().model_copy(update={'service_base_url': 'http://1.14.103.234:8123'})
- 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))
- ar = db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id == agent.id).order_by(AgentAvatar.is_default.desc(), AgentAvatar.sort_order))
- vr = db.scalar(select(AgentVoice).where(AgentVoice.agent_id == agent.id).order_by(AgentVoice.is_default.desc(), AgentVoice.sort_order))
- avatar, voice = db.get(Avatar, ar.avatar_id), db.get(CapabilityAsset, vr.capability_id)
- model = db.get(CapabilityAsset, voice.tts_model_id)
- assert voice.asset_code == 'builtin-voice-moss-junhao', 'Default voice changed; review before generation'
- assert model.status == 'ENABLED' and model.validation_status == 'AVAILABLE'
- assert voice.status == 'ENABLED' and voice.validation_status == 'AVAILABLE'
- avatar_id, voice_id = str(avatar.id), str(voice.id)
- provider_avatar, speaker = avatar.provider_avatar_id, voice.speaker_code
- profile = {'revision': REVISION, 'agent': agent.agent_name, 'agentSlug': agent.slug,
- 'agentDataVersion': agent.data_version, 'avatarName': avatar.avatar_name,
- 'providerAvatarId': provider_avatar, 'voice': voice.asset_name,
- 'voiceCapabilityId': voice.asset_code, 'engine': 'MOSS-TTS-Nano',
- 'speaker': speaker, 'speed': float(agent.voice_speed), 'materialVersion': 'v2',
- 'width': 480, 'height': 640, 'fps': 25, 'lipAndPlaybackInput': 'same mono 16kHz PCM16 WAV',
- 'pauseProcessing': 'silence below -48dB longer than 0.65s shortened to 0.32s'}
- response = client.get(str(settings.service_base_url).rstrip('/') + f'/api/v1/avatars/{provider_avatar}/assets/manifest.json')
- response.raise_for_status()
- profile['avatarManifestSha256'] = hashlib.sha256(response.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']
- progress_path = REPORT / 'generation.json'
- progress = json.loads(progress_path.read_text('utf-8')) if progress_path.exists() else {'profile': profile, 'clips': []}
- assert progress['profile'] == profile, 'Configuration changed; use a new revision'
- write_json(progress_path, progress)
- old_audio, old_video = manifest('narration-manifest.js'), manifest('narration-video-manifest.js')
- audio_manifest, video_manifest = copy.deepcopy(old_audio), copy.deepcopy(old_video)
- audio_manifest['voice'], video_manifest['profile'] = profile, profile
- renderer, engine = OpenGenerationWorker(None, settings, None, None), MossTtsService(settings)
- done = {c['key']: c for c in progress['clips']}
- try:
- for pack in audio_manifest['scripts']:
- videos = next(p for p in video_manifest['scripts'] if p['code'] == pack['code'])
- for clip in pack['steps']:
- key = f"{pack['code']}-step-{clip['number']:02}"
- text = clip['text']
- # Avoid repeating the step title as a second, almost identical sentence.
- if key == 'TASK-VIRTUAL-005-step-01': text = '第一步,根据故障现象,确认是哪一个液压泵发生故障。'
- if key == 'TASK-VIRTUAL-006-step-07': text = '第七步,按照正常操作步骤,操作架桥车。'
- if key == 'TASK-VIRTUAL-007-step-05': text = '第五步,取下发生故障的前部液压泵。'
- if key == 'TASK-VIRTUAL-008-step-08': text = '第八步,用两个油堵封住大腔的两个油口,并擦净阀座结合面。'
- if key == 'TASK-VIRTUAL-008-step-09': text = '第九步,准备安装大腔平衡阀。擦净平衡阀及阀座结合面,然后取下两个油堵。'
- assert not any(s in text for s in ['原稿', '未展开', '没有展开']), key
- folder = WEB / 'public/legacy/virtual-training/narration-revisions' / REVISION
- folder.mkdir(parents=True, exist_ok=True)
- audio, video = folder / f'{key}.wav', folder / f'{key}.mp4'
- raw, capture = REPORT / f'{key}-raw.wav', REPORT / f'{key}.webm'
- if key in done:
- row = done[key]
- assert row['text'] == text and digest(audio) == row['audioSha256'] and digest(video) == row['videoSha256'], key
- else:
- print(f'{key}: synthesize', flush=True)
- if not raw.exists():
- generated = engine.synthesize(text, voice_code=speaker, speed=profile['speed'])
- shutil.copyfile(engine.output_path(generated['id']), raw)
- prepared = prepare_audio(settings, raw, audio)
- print(f'{key}: render {prepared["duration"]:.2f}s', flush=True)
- command = GenerationCommand(title=key, text=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, '-v', '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 = probe(settings, video)
- 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'], v['r_frame_rate']) == (480, 640, 'h264', '25/1')
- assert a['codec_name'] == 'aac'
- duration = float(info['format']['duration'])
- assert abs(duration - prepared['duration']) < .7, (key, duration, prepared)
- subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-i', str(video), '-f', 'null', '-'], check=True)
- row = {'key': key, 'code': pack['code'], 'number': clip['number'], 'title': clip['expectedTitle'],
- 'text': text, **prepared, 'videoDuration': duration, 'audioSha256': digest(audio),
- 'videoSha256': digest(video), 'audioUrl': '/' + audio.relative_to(WEB / 'public').as_posix(),
- 'videoUrl': '/' + video.relative_to(WEB / 'public').as_posix()}
- progress['clips'].append(row); write_json(progress_path, progress)
- print(f'{key}: complete ({len(progress["clips"])}/39)', flush=True)
- clip.update(text=text, url=row['audioUrl'], stepDuration=row['duration'], stepSha256=row['audioSha256'])
- vc = next(c for c in videos['steps'] if c['stepCode'] == clip['stepCode'])
- vc.update(text=text, url=row['videoUrl'], duration=row['videoDuration'], sha256=row['videoSha256'])
- finally:
- engine.close()
- assert len(progress['clips']) == 39
- progress['clips'].sort(key=lambda clip: clip['key'])
- write_json(progress_path, progress)
- # The two valve-review steps share one prompt; regenerate it in the same
- # voice so video-to-audio fallback and safety playback do not change timbre.
- safety_engine = MossTtsService(settings)
- try:
- for pack in audio_manifest['scripts']:
- for clip in pack['steps']:
- if not clip.get('safetyText'): continue
- key = hashlib.sha256(clip['safetyText'].encode()).hexdigest()[:16]
- raw = REPORT / f'safety-{key}-raw.wav'
- wav = WEB / f'public/legacy/virtual-training/narration-revisions/{REVISION}/safety-{key}.wav'
- if not raw.exists():
- generated = safety_engine.synthesize(clip['safetyText'], voice_code=speaker, speed=profile['speed'])
- shutil.copyfile(safety_engine.output_path(generated['id']), raw)
- prepared = prepare_audio(settings, raw, wav)
- clip.update(safetyUrl='/' + wav.relative_to(WEB / 'public').as_posix(),
- safetyDuration=prepared['duration'], safetySha256=digest(wav))
- finally:
- safety_engine.close()
- write_json(REPORT / 'audio-manifest.json', audio_manifest)
- write_json(REPORT / 'video-manifest.json', video_manifest)
- cards = []
- for row in progress['clips']:
- url = '../../../unreal_tran_web/public' + row['videoUrl']
- wav = '../../../unreal_tran_web/public' + row['audioUrl']
- cards.append(f'<article><h2>{html.escape(row["key"])} · {html.escape(row["title"])}</h2><p>{html.escape(row["text"])}</p><video controls preload="none" src="{url}"></video><p><a href="{wav}">试听音频</a> · {row["videoDuration"]:.2f} 秒</p></article>')
- (REPORT / 'index.html').write_text('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>四套流程 · Junhao 新讲解</title><style>body{font:16px/1.7 system-ui;background:#eff5f3;color:#143e36;max-width:1150px;margin:30px auto;padding:20px}main{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}article{padding:18px;background:white;border-radius:12px}h2{font-size:16px}video{width:100%;aspect-ratio:3/4}a{color:#00816b}</style><h1>005~008 · 教员1 v2 / MOSS · Junhao</h1><p>39 段新讲解;保留旧版。长静音经过压缩,口型和播放使用同一份音频。技术检查通过不等同于人工发音与口型验收。</p><main>' + ''.join(cards) + '</main><script>document.addEventListener("play",e=>document.querySelectorAll("video,audio").forEach(x=>{if(x!==e.target)x.pause()}),true)</script></html>', 'utf-8')
- print('ALL 39 COMPLETE; manifests ready for review and activation', flush=True)
-
-
- if __name__ == '__main__': main()
|