"""Resumable intro-only release. Read current agent defaults; retain all older media.""" from pathlib import Path import argparse import hashlib import importlib.util import json import re import shutil import subprocess import sys import threading ROOT = Path(__file__).resolve().parents[3] REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-introduction-narration-20260924' PUBLIC = ROOT / 'unreal_tran/unreal_tran_web/public' REVISION = 'intro-20260924' OUTPUT = PUBLIC / 'legacy/virtual-training/narration-revisions' / REVISION sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api')) import httpx import numpy as np import soundfile as sf from sqlalchemy import select 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.services.moss_tts_service import MossTtsService from app.services.open_generation_worker import OpenGenerationWorker from app.schemas.open_platform import GenerationCommand def module(filename): spec=importlib.util.spec_from_file_location(filename, Path(__file__).with_name(filename+'.py')) result=importlib.util.module_from_spec(spec);spec.loader.exec_module(result);return result helpers=module('bridge-narration-current-voice') def spoken(text): return text.replace('0MPa','零兆帕').replace('2个','两个').replace('——',',').replace('“','').replace('”','') def chunks(text): # Cut only at complete sentences/clauses, keeping enough context for fluent speech. sentences=re.findall(r'[^。!?;\n]+[。!?;\n]?',text) result=[];current='' for sentence in sentences: if len(current)+len(sentence)>155 and current.strip():result.append(current.strip());current='' current+=sentence if current.strip():result.append(current.strip()) return result def main(): parser=argparse.ArgumentParser();parser.add_argument('--check-only',action='store_true');args=parser.parse_args() REPORT.mkdir(parents=True,exist_ok=True);OUTPUT.mkdir(parents=True,exist_ok=True) # Existing shared rendering service, as used for the approved step videos. settings=get_settings().model_copy(update={'service_base_url':'http://1.14.103.234:8123'}) source=json.loads((REPORT/'sources.json').read_text('utf-8')) 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)) assert agent is not None,'Linked agent is missing' av=db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id==agent.id).order_by(AgentAvatar.is_default.desc(),AgentAvatar.sort_order)) vo=db.scalar(select(AgentVoice).where(AgentVoice.agent_id==agent.id).order_by(AgentVoice.is_default.desc(),AgentVoice.sort_order)) assert av and vo,'Default avatar or voice is missing' avatar,voice=db.get(Avatar,av.avatar_id),db.get(CapabilityAsset,vo.capability_id) model=db.get(CapabilityAsset,voice.tts_model_id) assert voice.asset_code.startswith('builtin-voice-moss-'),'Current default voice is not MOSS; review generation adapter' assert voice.status=='ENABLED' and voice.validation_status=='AVAILABLE','Default voice is not available' assert model.status=='ENABLED' and model.validation_status=='AVAILABLE','Default speech model is not available' avatar_id,voice_id=str(avatar.id),str(voice.id) profile={'revision':REVISION,'agentSlug':agent.slug,'agentVersion':agent.data_version,'avatar':avatar.avatar_name, 'providerAvatarId':avatar.provider_avatar_id,'voice':voice.asset_name,'voiceCode':voice.asset_code,'speaker':voice.speaker_code, 'speed':float(agent.voice_speed),'engine':'MOSS-TTS-Nano','width':480,'height':640,'fps':25, 'sourceSha256':helpers.digest(REPORT/'sources.json'),'lipAndPlaybackInput':'same mono 16kHz PCM16 WAV'} manifest=client.get(f"{str(settings.service_base_url).rstrip('/')}/api/v1/avatars/{profile['providerAvatarId']}/assets/manifest.json") manifest.raise_for_status() bundles=json.loads((ROOT/'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'] expected=next(x for x in bundles if x['id']==profile['providerAvatarId']) assert hashlib.sha256(manifest.content).hexdigest()==expected['bundle_sha256']['manifest.json'],'Avatar bundle changed' profile['avatarManifestSha256']=expected['bundle_sha256']['manifest.json'] engine=MossTtsService(settings);assert engine.readiness()['ready'],'Local MOSS runtime not ready' print(json.dumps({'profile':profile,'pages':len(source)},ensure_ascii=False),flush=True) if args.check_only:return path=REPORT/'generation.json' progress=json.loads(path.read_text('utf-8')) if path.exists() else {'profile':profile,'clips':[]} assert progress['profile']==profile,'Source/default configuration changed; create another release' helpers.write_json(path,progress) renderer=OpenGenerationWorker(None,settings,None,None) # Record old step media hashes once, so the quality check can prove preservation. old=REPORT/'previous-media.json' if not old.exists(): helpers.write_json(old,[{'path':p.relative_to(PUBLIC).as_posix(),'sha256':helpers.digest(p)} for p in (PUBLIC/'legacy/virtual-training').rglob('*') if p.suffix in {'.mp4','.wav'} and OUTPUT not in p.parents]) try: for page in source: key=page['key'];text=spoken(page['text']);audio=OUTPUT/(key+'.wav');video=OUTPUT/(key+'.mp4');poster=OUTPUT/(key+'.jpg') done=next((r for r in progress['clips'] if r['key']==key),None) if done: assert helpers.digest(audio)==done['audioSha256'] and helpers.digest(video)==done['videoSha256'];continue chunk_paths=[] for index,part in enumerate(chunks(text)): raw=REPORT/f'{key}-chunk-{index:02}.wav';meta=raw.with_suffix('.json') if raw.exists():assert json.loads(meta.read_text('utf-8'))=={'text':part,'profile':profile} else: print(f'{key}: speech {index+1}/{len(chunks(text))}',flush=True) result=engine.synthesize(part,voice_code=profile['speaker'],speed=profile['speed']) shutil.copyfile(engine.output_path(result['id']),raw);helpers.write_json(meta,{'text':part,'profile':profile}) chunk_paths.append(raw) samples=[];rate=None for raw in chunk_paths: pcm,sr=sf.read(raw,always_2d=True);assert rate in (None,sr);rate=sr;samples.append(pcm) combined=REPORT/(key+'-raw.wav');sf.write(combined,np.concatenate(samples),rate,subtype='PCM_16') prepared=REPORT/(key+'-prepared.wav');timing=helpers.prepare_audio(settings,combined,prepared) subprocess.run([settings.ffmpeg_binary,'-v','error','-y','-i',str(prepared),'-ac','1','-ar','16000','-c:a','pcm_s16le',str(audio)],check=True) assert sf.info(audio).duration