#!/usr/bin/env python3
from pathlib import Path
import datetime,hashlib,json,os,subprocess,urllib.parse,urllib.request
ROOT=Path('/data/video-pipeline/HaTramAudio/project/019-Buc-Tuong-Giua-Hai-Ban-Cong');PID=ROOT.name;RUN='run-20260721T065616Z-a7dbad2f';OWNER='Levy'
TEXT='Các bạn đang nghe truyện được phát từ Hạ Trâm Audio, chúc các bạn có những giây phút nghe truyện vui vẻ. Hãy ủng hộ chúng tôi bằng cách like video và đăng ký kênh.'
ENDPOINT='http://192.168.40.32:7861/voice/ngoc-huyen-clone'
def now():return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
def sha(p):
 h=hashlib.sha256()
 with p.open('rb') as f:
  for b in iter(lambda:f.read(8*1024*1024),b''):h.update(b)
 return h.hexdigest()
def guard():
 d=json.loads((ROOT/'.ownership-lock.json').read_text())
 for k,v in {'project_id':PID,'run_id':RUN,'owner':OWNER}.items():
  if d.get(k)!=v:raise RuntimeError('ownership mismatch '+k)
def probe(p):return json.loads(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration,size:stream=index,codec_name,profile,codec_type,width,height,pix_fmt,r_frame_rate,sample_rate,channels','-of','json',str(p)]))
def envfile(p):
 d={}
 for raw in p.read_text().splitlines():
  s=raw.strip()
  if s and not s.startswith('#') and '=' in s:
   k,v=s.split('=',1);d[k.strip()]=v.strip().strip("\"'")
 return d
def atomic(path,d):
 guard();tmp=path.with_name('.'+path.name+'.tmp');tmp.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n');os.replace(tmp,path)
def main():
 guard();m=json.loads((ROOT/'script/project-manifest.json').read_text());pe=m.get('gate_events',{}).get('poster')
 if not pe or m.get('steps',{}).get('poster')!='completed' or pe.get('status')!='completed' or pe.get('verified') is not True:raise RuntimeError('Poster Gate is not committed/current')
 canon=m.get('canon_sha256')
 if not canon or pe.get('canon_sha256')!=canon:raise RuntimeError('Poster Gate canon binding mismatch')
 poster=Path(pe['artifact']['path']);poster_hash=pe['artifact_sha256']
 if sha(poster)!=poster_hash:raise RuntimeError('poster hash drift')
 voice=ROOT/'audio/intro-voice.wav';sil=ROOT/'audio/intro-silence-2s.wav';full=ROOT/'audio/intro-full.wav';out=ROOT/'output/intro/intro.mp4';receipt=ROOT/'log/intro-render.json'
 if receipt.exists() and out.exists():
  old=json.loads(receipt.read_text())
  if old.get('status')=='completed' and old.get('verified') is True and old.get('source_canon_sha256')==canon and old.get('source_poster_sha256')==poster_hash and sha(out)==old.get('artifact_sha256'):
   print(json.dumps({'status':'completed','verified':True,'resume':True,'artifact_sha256':old['artifact_sha256']}));return
 payload=urllib.parse.urlencode({'text':TEXT,'style':'doc_truyen','speed':'1.0','denoise':'true'}).encode();tmpv=voice.with_name('.intro-voice.tmp.wav')
 if not voice.exists():
  req=urllib.request.Request(ENDPOINT,data=payload,headers={'Content-Type':'application/x-www-form-urlencoded'},method='POST')
  with urllib.request.urlopen(req,timeout=900) as r, tmpv.open('wb') as f:
   if r.status!=200:raise RuntimeError('intro TTS HTTP '+str(r.status))
   while b:=r.read(1024*1024):f.write(b)
  guard();os.replace(tmpv,voice)
 subprocess.check_call(['ffmpeg','-hide_banner','-loglevel','error','-y','-f','lavfi','-i','anullsrc=r=48000:cl=mono','-t','2','-c:a','pcm_s16le',str(sil)])
 concat=ROOT/'work/intro-audio.ffconcat';concat.write_text("ffconcat version 1.0\nfile '"+str(voice).replace("'","'\\''")+"'\nfile '"+str(sil).replace("'","'\\''")+"'\n")
 tmpa=full.with_name('.intro-full.tmp.wav');subprocess.check_call(['ffmpeg','-hide_banner','-loglevel','error','-y','-f','concat','-safe','0','-i',str(concat),'-ar','48000','-ac','1','-c:a','pcm_s16le',str(tmpa)]);guard();os.replace(tmpa,full)
 ap=probe(full);adur=float(ap['format']['duration']);cfg=envfile(Path.home()/'.config/ha-tram-audio/worker.env');env=os.environ.copy();env['SSHPASS']=cfg['WORKER_PASSWORD'];tmpout=out.with_name('.intro.tmp.mp4');tmpout.unlink(missing_ok=True)
 remote=['ffmpeg','-hide_banner','-loglevel','error','-y','-loop','1','-framerate','30','-i',str(poster),'-i',str(full),'-c:v','h264_nvenc','-preset','p4','-tune','hq','-rc','vbr','-cq','21','-b:v','0','-profile:v','high','-level:v','4.0','-pix_fmt','yuv420p','-r','30','-video_track_timescale','15360','-c:a','aac','-b:a','192k','-ar','48000','-ac','1','-t',f'{adur:.6f}','-movflags','+faststart',str(tmpout)]
 subprocess.check_call(['sshpass','-e','ssh','-o','BatchMode=no','-o','StrictHostKeyChecking=accept-new',f"{cfg['WORKER_USER']}@{cfg['WORKER_HOST']}",*remote],env=env)
 vp=probe(tmpout);streams=vp['streams'];vs=[x for x in streams if x.get('codec_type')=='video'];au=[x for x in streams if x.get('codec_type')=='audio'];vd=float(vp['format']['duration'])
 if len(vs)!=1 or len(au)!=1 or vs[0].get('codec_name')!='h264' or vs[0].get('width')!=1920 or vs[0].get('height')!=1080 or vs[0].get('r_frame_rate')!='30/1' or au[0].get('codec_name')!='aac' or au[0].get('sample_rate')!='48000' or au[0].get('channels')!=1 or abs(vd-adur)>0.04:raise RuntimeError('intro technical probe failed')
 guard();os.replace(tmpout,out)
 d={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':canon,'source_poster_sha256':poster_hash,'intro_text':TEXT,'intro_text_sha256':hashlib.sha256(TEXT.encode()).hexdigest(),'voice_path':str(voice),'voice_sha256':sha(voice),'silence_path':str(sil),'silence_duration_seconds':2.0,'audio_path':str(full),'audio_sha256':sha(full),'audio_duration_seconds':adur,'artifact_path':str(out),'artifact_sha256':sha(out),'artifact_bytes':out.stat().st_size,'duration_seconds':vd,'duration_delta_seconds':abs(vd-adur),'encoder':'h264_nvenc via SSH GPU worker','probe':vp,'completed_at':now()};atomic(receipt,d);print(json.dumps({'status':'completed','verified':True,'duration':vd,'artifact_sha256':d['artifact_sha256']}))
if __name__=='__main__':main()
