Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 

130 řádky
8.9 KiB

  1. """Resumable intro-only release. Read current agent defaults; retain all older media."""
  2. from pathlib import Path
  3. import argparse
  4. import hashlib
  5. import importlib.util
  6. import json
  7. import re
  8. import shutil
  9. import subprocess
  10. import sys
  11. import threading
  12. ROOT = Path(__file__).resolve().parents[3]
  13. REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-introduction-narration-20260924'
  14. PUBLIC = ROOT / 'unreal_tran/unreal_tran_web/public'
  15. REVISION = 'intro-20260924'
  16. OUTPUT = PUBLIC / 'legacy/virtual-training/narration-revisions' / REVISION
  17. sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api'))
  18. import httpx
  19. import numpy as np
  20. import soundfile as sf
  21. from sqlalchemy import select
  22. from app.config import get_settings
  23. from app.persistence.database import get_session_factory
  24. from app.persistence.agent_models import Agent, AgentAvatar, AgentVoice
  25. from app.persistence.asset_models import Avatar, CapabilityAsset
  26. from app.services.moss_tts_service import MossTtsService
  27. from app.services.open_generation_worker import OpenGenerationWorker
  28. from app.schemas.open_platform import GenerationCommand
  29. def module(filename):
  30. spec=importlib.util.spec_from_file_location(filename, Path(__file__).with_name(filename+'.py'))
  31. result=importlib.util.module_from_spec(spec);spec.loader.exec_module(result);return result
  32. helpers=module('bridge-narration-current-voice')
  33. def spoken(text):
  34. return text.replace('0MPa','零兆帕').replace('2个','两个').replace('——',',').replace('“','').replace('”','')
  35. def chunks(text):
  36. # Cut only at complete sentences/clauses, keeping enough context for fluent speech.
  37. sentences=re.findall(r'[^。!?;\n]+[。!?;\n]?',text)
  38. result=[];current=''
  39. for sentence in sentences:
  40. if len(current)+len(sentence)>155 and current.strip():result.append(current.strip());current=''
  41. current+=sentence
  42. if current.strip():result.append(current.strip())
  43. return result
  44. def main():
  45. parser=argparse.ArgumentParser();parser.add_argument('--check-only',action='store_true');args=parser.parse_args()
  46. REPORT.mkdir(parents=True,exist_ok=True);OUTPUT.mkdir(parents=True,exist_ok=True)
  47. # Existing shared rendering service, as used for the approved step videos.
  48. settings=get_settings().model_copy(update={'service_base_url':'http://1.14.103.234:8123'})
  49. source=json.loads((REPORT/'sources.json').read_text('utf-8'))
  50. with httpx.Client(trust_env=False,timeout=30) as client:
  51. widget=client.get('http://127.0.0.1:6180/api/auth/v1/system-config/public').json()['data']['digitalHumanWidget']
  52. with get_session_factory()() as db:
  53. agent=db.scalar(select(Agent).where(Agent.slug==widget['agentSlug'],Agent.is_delete==0))
  54. assert agent is not None,'Linked agent is missing'
  55. av=db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id==agent.id).order_by(AgentAvatar.is_default.desc(),AgentAvatar.sort_order))
  56. vo=db.scalar(select(AgentVoice).where(AgentVoice.agent_id==agent.id).order_by(AgentVoice.is_default.desc(),AgentVoice.sort_order))
  57. assert av and vo,'Default avatar or voice is missing'
  58. avatar,voice=db.get(Avatar,av.avatar_id),db.get(CapabilityAsset,vo.capability_id)
  59. model=db.get(CapabilityAsset,voice.tts_model_id)
  60. assert voice.asset_code.startswith('builtin-voice-moss-'),'Current default voice is not MOSS; review generation adapter'
  61. assert voice.status=='ENABLED' and voice.validation_status=='AVAILABLE','Default voice is not available'
  62. assert model.status=='ENABLED' and model.validation_status=='AVAILABLE','Default speech model is not available'
  63. avatar_id,voice_id=str(avatar.id),str(voice.id)
  64. profile={'revision':REVISION,'agentSlug':agent.slug,'agentVersion':agent.data_version,'avatar':avatar.avatar_name,
  65. 'providerAvatarId':avatar.provider_avatar_id,'voice':voice.asset_name,'voiceCode':voice.asset_code,'speaker':voice.speaker_code,
  66. 'speed':float(agent.voice_speed),'engine':'MOSS-TTS-Nano','width':480,'height':640,'fps':25,
  67. 'sourceSha256':helpers.digest(REPORT/'sources.json'),'lipAndPlaybackInput':'same mono 16kHz PCM16 WAV'}
  68. manifest=client.get(f"{str(settings.service_base_url).rstrip('/')}/api/v1/avatars/{profile['providerAvatarId']}/assets/manifest.json")
  69. manifest.raise_for_status()
  70. bundles=json.loads((ROOT/'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors']
  71. expected=next(x for x in bundles if x['id']==profile['providerAvatarId'])
  72. assert hashlib.sha256(manifest.content).hexdigest()==expected['bundle_sha256']['manifest.json'],'Avatar bundle changed'
  73. profile['avatarManifestSha256']=expected['bundle_sha256']['manifest.json']
  74. engine=MossTtsService(settings);assert engine.readiness()['ready'],'Local MOSS runtime not ready'
  75. print(json.dumps({'profile':profile,'pages':len(source)},ensure_ascii=False),flush=True)
  76. if args.check_only:return
  77. path=REPORT/'generation.json'
  78. progress=json.loads(path.read_text('utf-8')) if path.exists() else {'profile':profile,'clips':[]}
  79. assert progress['profile']==profile,'Source/default configuration changed; create another release'
  80. helpers.write_json(path,progress)
  81. renderer=OpenGenerationWorker(None,settings,None,None)
  82. # Record old step media hashes once, so the quality check can prove preservation.
  83. old=REPORT/'previous-media.json'
  84. if not old.exists():
  85. 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])
  86. try:
  87. for page in source:
  88. key=page['key'];text=spoken(page['text']);audio=OUTPUT/(key+'.wav');video=OUTPUT/(key+'.mp4');poster=OUTPUT/(key+'.jpg')
  89. done=next((r for r in progress['clips'] if r['key']==key),None)
  90. if done:
  91. assert helpers.digest(audio)==done['audioSha256'] and helpers.digest(video)==done['videoSha256'];continue
  92. chunk_paths=[]
  93. for index,part in enumerate(chunks(text)):
  94. raw=REPORT/f'{key}-chunk-{index:02}.wav';meta=raw.with_suffix('.json')
  95. if raw.exists():assert json.loads(meta.read_text('utf-8'))=={'text':part,'profile':profile}
  96. else:
  97. print(f'{key}: speech {index+1}/{len(chunks(text))}',flush=True)
  98. result=engine.synthesize(part,voice_code=profile['speaker'],speed=profile['speed'])
  99. shutil.copyfile(engine.output_path(result['id']),raw);helpers.write_json(meta,{'text':part,'profile':profile})
  100. chunk_paths.append(raw)
  101. samples=[];rate=None
  102. for raw in chunk_paths:
  103. pcm,sr=sf.read(raw,always_2d=True);assert rate in (None,sr);rate=sr;samples.append(pcm)
  104. combined=REPORT/(key+'-raw.wav');sf.write(combined,np.concatenate(samples),rate,subtype='PCM_16')
  105. prepared=REPORT/(key+'-prepared.wav');timing=helpers.prepare_audio(settings,combined,prepared)
  106. subprocess.run([settings.ffmpeg_binary,'-v','error','-y','-i',str(prepared),'-ac','1','-ar','16000','-c:a','pcm_s16le',str(audio)],check=True)
  107. assert sf.info(audio).duration<settings.open_render_max_seconds
  108. capture=REPORT/(key+'.webm');print(f'{key}: render {timing["duration"]:.1f}s',flush=True)
  109. command=GenerationCommand(title=page['title'],text=text,avatarId=avatar_id,voiceId=voice_id,speed=profile['speed'],width=480,height=640,subtitles=False)
  110. renderer.capture(command,profile['providerAvatarId'],audio,None,capture,threading.Event())
  111. 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)
  112. info=helpers.probe(settings,video);v=next(s for s in info['streams'] if s['codec_type']=='video')
  113. assert (v['width'],v['height'],v['r_frame_rate'])==(480,640,'25/1')
  114. assert abs(float(info['format']['duration'])-timing['duration'])<.7
  115. subprocess.run([settings.ffmpeg_binary,'-v','error','-y','-ss','0.1','-i',str(video),'-frames:v','1',str(poster)],check=True)
  116. row={**page,'text':text,**timing,'videoDuration':float(info['format']['duration']),
  117. 'audioUrl':'/'+audio.relative_to(PUBLIC).as_posix(),'videoUrl':'/'+video.relative_to(PUBLIC).as_posix(),'posterUrl':'/'+poster.relative_to(PUBLIC).as_posix(),
  118. 'audioSha256':helpers.digest(audio),'videoSha256':helpers.digest(video)}
  119. progress['clips'].append(row);helpers.write_json(path,progress)
  120. print(f'{key}: COMPLETE {len(progress["clips"])}/{len(source)}',flush=True)
  121. finally:engine.close()
  122. print('ALL INTRO CLIPS GENERATED',flush=True)
  123. if __name__=='__main__':main()