|
- """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<settings.open_render_max_seconds
- capture=REPORT/(key+'.webm');print(f'{key}: render {timing["duration"]:.1f}s',flush=True)
- command=GenerationCommand(title=page['title'],text=text,avatarId=avatar_id,voiceId=voice_id,speed=profile['speed'],width=480,height=640,subtitles=False)
- renderer.capture(command,profile['providerAvatarId'],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=helpers.probe(settings,video);v=next(s for s in info['streams'] if s['codec_type']=='video')
- assert (v['width'],v['height'],v['r_frame_rate'])==(480,640,'25/1')
- assert abs(float(info['format']['duration'])-timing['duration'])<.7
- subprocess.run([settings.ffmpeg_binary,'-v','error','-y','-ss','0.1','-i',str(video),'-frames:v','1',str(poster)],check=True)
- row={**page,'text':text,**timing,'videoDuration':float(info['format']['duration']),
- 'audioUrl':'/'+audio.relative_to(PUBLIC).as_posix(),'videoUrl':'/'+video.relative_to(PUBLIC).as_posix(),'posterUrl':'/'+poster.relative_to(PUBLIC).as_posix(),
- 'audioSha256':helpers.digest(audio),'videoSha256':helpers.digest(video)}
- progress['clips'].append(row);helpers.write_json(path,progress)
- print(f'{key}: COMPLETE {len(progress["clips"])}/{len(source)}',flush=True)
- finally:engine.close()
- print('ALL INTRO CLIPS GENERATED',flush=True)
-
- if __name__=='__main__':main()
|