#!/usr/bin/env python3
from pathlib import Path
import hashlib,json,subprocess,datetime,re,wave,contextlib,urllib.parse,urllib.request
ROOT=Path('/data/video-pipeline/HaTramAudio/project/026-Ngay-Chung-Ta-Xoa-Ten-Khoi-Giay-Dang-Ky-Ket-Hon'); RUN='run-20260722T033406Z-3df8cc80'; END='http://192.168.40.32:7861/voice/ngoc-huyen-clone'; CANON='a124ee39d569ab043105b755f9a3c10a2c92a1b3edccb2162ec2ab8767fc1f7f'
SRC=ROOT/'script/spoken-narration.txt'; OUT=ROOT/'audio'; WORK=ROOT/'work/tts'; OUT.mkdir(exist_ok=True);WORK.mkdir(exist_ok=True)
def sha(p):
 h=hashlib.sha256()
 with p.open('rb') as f:
  for b in iter(lambda:f.read(8<<20),b''):h.update(b)
 return h.hexdigest()
def atomic(p,o):
 q=p.with_suffix(p.suffix+'.tmp');q.write_text(json.dumps(o,ensure_ascii=False,indent=2)+'\n');q.replace(p)
def probe(p):
 x=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration:stream=codec_type,codec_name,sample_rate,channels','-of','json',str(p)]));return x
assert sha(SRC)==CANON
text=SRC.read_text();paras=[x.strip() for x in re.split(r'\n\s*\n',text) if x.strip()];chunks=[];cur=''
for p in paras:
 if len(cur)+len(p)+2<=2800:cur=(cur+'\n\n'+p).strip()
 else:
  if cur:chunks.append(cur)
  cur=p
if cur:chunks.append(cur)
manifest={'schema_version':1,'project_id':'026','run_id':RUN,'status':'running','verified':False,'source_canon_sha256':CANON,'spoken_narration_sha256':CANON,'planned_segments':len(chunks),'segments':[]}
hot=WORK/'hot-manifest.json'
if hot.exists():manifest=json.loads(hot.read_text())
completed={x['index']:x for x in manifest.get('segments',[]) if x.get('status')=='completed'}
for i,ch in enumerate(chunks,1):
 p=OUT/f'segment-{i:04d}.wav';th=hashlib.sha256(ch.encode()).hexdigest()
 if i in completed and p.exists() and completed[i]['text_sha256']==th and sha(p)==completed[i]['artifact_sha256']:continue
 attempt={'schema_version':1,'project_id':'026','run_id':RUN,'index':i,'status':'creating','source_canon_sha256':CANON,'spoken_narration_sha256':CANON,'text_sha256':th,'config':{'style':'doc_truyen','speed':'1.0','denoise':'true'},'created_at':datetime.datetime.now(datetime.timezone.utc).isoformat()};atomic(WORK/f'attempt-{i:04d}.json',attempt)
 data=urllib.parse.urlencode({'text':ch,'style':'doc_truyen','speed':'1.0','denoise':'true'}).encode();req=urllib.request.Request(END,data=data,method='POST',headers={'Content-Type':'application/x-www-form-urlencoded'})
 tmp=p.with_suffix('.wav.tmp')
 with urllib.request.urlopen(req,timeout=600) as r:tmp.write_bytes(r.read())
 tmp.replace(p);pr=probe(p);seg={'index':i,'status':'completed','text_sha256':th,'path':str(p.relative_to(ROOT)),'size':p.stat().st_size,'artifact_sha256':sha(p),'probe':pr};completed[i]=seg;manifest['segments']=[completed[k] for k in sorted(completed)];atomic(hot,manifest);attempt.update({'status':'completed','artifact_sha256':seg['artifact_sha256']});atomic(WORK/f'attempt-{i:04d}.json',attempt)
# normalize and concat losslessly through ffmpeg concat demuxer
lst=WORK/'segments.ffconcat';lst.write_text('ffconcat version 1.0\n'+''.join("file '%s'\n"%str((OUT/f'segment-{i:04d}.wav').resolve()).replace("'","'\\''") for i in range(1,len(chunks)+1)))
full=OUT/'story-full.wav';subprocess.run(['ffmpeg','-y','-v','error','-f','concat','-safe','0','-i',str(lst),'-c','copy',str(full)],check=True)
pr=probe(full);manifest.update({'status':'completed','verified':True,'output_path':'audio/story-full.wav','output_size':full.stat().st_size,'output_sha256':sha(full),'output_probe':pr,'completed_at':datetime.datetime.now(datetime.timezone.utc).isoformat()});atomic(hot,manifest);atomic(ROOT/'script/tts-manifest.json',manifest);print(json.dumps({'status':'completed','segments':len(chunks),'bytes':full.stat().st_size,'sha256':manifest['output_sha256'],'duration':pr['format']['duration']}))
