#!/usr/bin/env python3
"""Render Everyday Red Flags 023: fake equipment check job scam red flag.

Visual policy: high-quality AI keyframes + text-only active-word subtitles.
No renderer arrows, boxes, circles, progress bars, labels, or caption boxes beyond subtitles.
"""
from __future__ import annotations
import argparse, datetime, hashlib, json, math, re, shutil, subprocess, sys, tempfile, time
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

import requests

from autoshorts.website_companion import export_companion_candidate
from PIL import Image, ImageDraw, ImageFont, ImageFilter

OUT_DIR = ROOT / "data" / "post_candidates" / "everyday-red-flags-fake-equipment-check"
VERSION = "chatterbox_gianna_clone_premium_v1_readable_silent_2line_clean_ai"
VERSION_DIR = OUT_DIR / VERSION
ASSET_DIR = VERSION_DIR / "scene_keyframes"
AUDIO = VERSION_DIR / "erf-023_voiceover_chatterbox_gianna_clone_premium_v1.wav"
WORDS_JSON = VERSION_DIR / "erf-023_voiceover_chatterbox_gianna_clone_premium_v1.faster_whisper_words.json"
VIDEO_NO_AUDIO = VERSION_DIR / "video_no_audio_forced_words_2line.mp4"
FINAL = VERSION_DIR / "erf-023_fake_equipment_check_chatterbox_gianna_clone_premium_v1_1080x1920_review.mp4"
CONTACT = VERSION_DIR / "contact_sheet_keyframes.png"
PACKAGE_DIR = VERSION_DIR / "youtube_private_upload_package"
PACKAGE = PACKAGE_DIR / "unused.json"  # overwritten below
MEDIA_CACHE = Path.home() / ".hermes" / "media_cache" / "autoshorts"
DEFAULT_WEBSITE_ROOT = Path("/home/agent/projects/TrueTraceShorts_WebSite")
DEFAULT_COMPANION_SPEC = ROOT / "data" / "website_companion_specs" / "erf-023-fake-equipment-check.json"
REVIEW_PACKAGE = VERSION_DIR / "review_package_chatterbox.json"
WIDTH, HEIGHT, FPS = 1080, 1920, 30
CANDIDATE_ID = "erf-023-fake-equipment-check"
QA_SECONDS = (1, 7, 14, 21, 27, 33)
AI_SCENE_SOURCES = [
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095105_0e2f5412.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095203_dac4a6a2.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095306_3c6c9928.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095414_0e4f2bf0.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095519_8e4e3c6b.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_095623_e6320b23.png",
]
TITLE = "Remote Job Scam? Don’t Pay First"
DESCRIPTION = "A remote job can look real until it asks you to pay before you earn.\n\nThe red flag is the upfront equipment fee: a real employer does not ask you to buy your own laptop kit through a message link before payroll is set.\n\nSafer move: do not pay from the chat. Verify the company through a channel you find yourself, and never send card details or transfer money to start a job.\n\nRule: if you must pay to get paid, stop.\n\n#JobScam #RemoteWork #ScamAlert #MoneySafety #EverydayRedFlags"
SCRIPT = (
    "Remote job offer, but they want money first? Stop. "
    "This scam looks exciting because the job feels already yours. "
    "Then comes the equipment check: pay for a laptop kit, training, or account setup. "
    "That is the red flag. Real employers do not make you pay through a message link before you earn. "
    "One small fee can become a bigger loss, or expose your card details. "
    "The safer move is simple: do not pay from the chat. "
    "Open the company website yourself, verify the recruiter, and ask for payroll documents through official channels. "
    "Rule: if you must pay to get paid, stop."
)
VOICE_CHUNKS = [
    "Remote job offer, but they want money first? Stop.",
    "This scam looks exciting because the job feels already yours.",
    "Then comes the equipment check: pay for a laptop kit, training, or account setup.",
    "That is the red flag. Real employers do not make you pay through a message link before you earn.",
    "One small fee can become a bigger loss, or expose your card details.",
    "The safer move is simple: do not pay from the chat.",
    "Open the company website yourself, verify the recruiter, and ask for payroll documents through official channels.",
    "Rule: if you must pay to get paid, stop.",
]
SCENE_DURATIONS = [4.2, 5.0, 6.4, 5.4, 7.0, 8.0]


def font(size:int,bold:bool=False):
    candidates=[
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    ]
    for c in candidates:
        if Path(c).exists(): return ImageFont.truetype(c,size=size)
    return ImageFont.load_default()

F12=font(28); F14=font(34); F16=font(38); F18=font(44); F20=font(52); F24=font(62,True); F30=font(76,True)


def sha256(path:Path)->str:
    h=hashlib.sha256()
    with path.open('rb') as f:
        for chunk in iter(lambda:f.read(1024*1024), b''): h.update(chunk)
    return h.hexdigest()


def ffprobe_duration(path:Path)->float:
    return float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','default=nw=1:nk=1',str(path)],text=True).strip())


def draw_round(draw, xy, r, fill, outline=None, width=1):
    draw.rounded_rectangle(xy, radius=r, fill=fill, outline=outline, width=width)


def center(draw, y, text, fnt, fill=(245,247,255)):
    b=draw.textbbox((0,0),text,font=fnt); x=(WIDTH-(b[2]-b[0]))//2
    draw.text((x,y),text,font=fnt,fill=fill)


def wrap(draw, text, fnt, max_w):
    words=text.split(); lines=[]; cur=[]
    for w in words:
        test=' '.join(cur+[w]); b=draw.textbbox((0,0),test,font=fnt)
        if cur and b[2]-b[0] > max_w:
            lines.append(' '.join(cur)); cur=[w]
        else: cur.append(w)
    if cur: lines.append(' '.join(cur))
    return lines


def background(accent=(30,70,120)):
    img=Image.new('RGB',(WIDTH,HEIGHT),(9,12,22))
    px=img.load()
    for y in range(HEIGHT):
        for x in range(WIDTH):
            # soft vertical/radial premium gradient
            dx=(x-WIDTH*0.72)/WIDTH; dy=(y-HEIGHT*0.20)/HEIGHT
            glow=max(0,1-(dx*dx+dy*dy)*4.2)
            base=10+int(16*(1-y/HEIGHT))
            px[x,y]=(base+int(accent[0]*0.10*glow), base+int(accent[1]*0.12*glow), 24+int(accent[2]*0.18*glow))
    return img.filter(ImageFilter.GaussianBlur(0.15))


def draw_job_chat(draw, x=92, y=245, w=896, h=1030, fee=True, paid=False):
    draw_round(draw,(x,y,x+w,y+h),58,(18,22,34),(66,75,102),2)
    draw_round(draw,(x+30,y+58,x+w-30,y+h-54),34,(244,247,252))
    draw.text((x+72,y+95),'Hiring chat',font=F20,fill=(25,32,48))
    draw.text((x+w-280,y+108),'Today 10:42',font=F12,fill=(96,106,124))
    draw.line((x+58,y+172,x+w-58,y+172),fill=(214,221,232),width=2)
    draw.text((x+72,y+218),'Remote Operations Assistant',font=F20,fill=(24,36,58))
    draw.text((x+72,y+278),'Status: selected for onboarding',font=F16,fill=(36,118,82))
    draw_round(draw,(x+72,y+370,x+w-120,y+535),24,(232,238,248),(205,214,228),2)
    draw.text((x+105,y+402),'Great news — you are approved.',font=F16,fill=(32,42,64))
    draw.text((x+105,y+458),'We can start this week.',font=F16,fill=(32,42,64))
    draw_round(draw,(x+165,y+580,x+w-72,y+815),24,(255,248,225),(230,184,70),2)
    if fee:
        draw.text((x+205,y+635),'equipment',font=F24,fill=(132,55,28))
        draw.text((x+205,y+705),'check fee.',font=F24,fill=(132,55,28))
        draw.text((x+205,y+775),'Refunded after first shift.',font=F14,fill=(88,57,10))
    else:
        draw.text((x+205,y+640),'Official onboarding only.',font=F20,fill=(35,85,60))
        draw.text((x+205,y+710),'No payment in chat.',font=F20,fill=(35,85,60))
    draw_round(draw,(x+72,y+870,x+w-72,y+965),24,(238,242,248),(210,218,230),2)
    draw.text((x+110,y+902),'Card details or transfer link',font=F16,fill=(80,90,112))
    draw.text((x+110,y+945),'appear next if you keep going.',font=F12,fill=(118,126,142))
    if paid:
        draw_round(draw,(x+120,y+1025,x+w-120,y+1135),24,(80,30,38),(210,90,100),2)
        draw.text((x+165,y+1055),'MONEY + CARD DETAILS AT RISK',font=F16,fill=(255,230,230))


def draw_company_check(draw, x=120, y=410, w=840, h=690):
    draw_round(draw,(x,y,x+w,y+h),42,(238,243,250),(76,95,132),2)
    draw.text((x+60,y+65),'Safer verification',font=F24,fill=(24,36,58))
    rows=[('1','Search the company yourself'),('2','Use the official careers page'),('3','Ask for payroll documents'),('4','Never pay from the chat')]
    yy=y+175
    for num,text in rows:
        draw_round(draw,(x+60,yy,x+132,yy+72),22,(32,118,86))
        b=draw.textbbox((0,0),num,font=F18); draw.text((x+96-(b[2]-b[0])//2,yy+13),num,font=F18,fill=(255,255,255))
        draw.text((x+160,yy+12),text,font=F18,fill=(32,44,66))
        yy+=105


def draw_rule_card(draw, lines, y=430, warn_index=1):
    draw_round(draw,(100,y,980,y+700),52,(240,244,250),(78,92,126),2)
    for i,line in enumerate(lines):
        fill=(170,56,38) if i==warn_index else (25,36,58)
        b=draw.textbbox((0,0),line,font=F30)
        draw.text(((WIDTH-(b[2]-b[0]))//2,y+140+i*150),line,font=F30,fill=fill)


def make_scene(idx:int, path:Path):
    accents=[(38,78,150),(120,58,48),(118,72,28),(140,56,42),(105,48,72),(36,112,90),(42,86,150),(32,92,90)]
    img=background(accents[(idx-1)%len(accents)])
    draw=ImageDraw.Draw(img)
    if idx==1:
        center(draw,70,'EVERYDAY RED FLAGS',F18,(196,211,255))
        center(draw,132,'REMOTE JOB?',F30,(255,255,255))
        center(draw,226,"DON’T PAY FIRST",F30,(255,218,89))
        draw_job_chat(draw, y=345, fee=True, paid=False)
    elif idx==2:
        center(draw,92,'WHY IT FEELS REAL',F30,(255,218,89))
        draw_job_chat(draw, y=310, fee=False, paid=False)
    elif idx==3:
        center(draw,92,'THE EQUIPMENT CHECK',F30,(255,218,89))
        draw_job_chat(draw, y=300, fee=True, paid=False)
    elif idx==4:
        center(draw,92,'THE RED FLAG',F30,(255,218,89))
        draw_rule_card(draw,['PAY BEFORE','YOU EARN','= STOP'],warn_index=0)
    elif idx==5:
        center(draw,92,'WHAT IS AT STAKE',F30,(255,218,89))
        draw_job_chat(draw, y=305, fee=True, paid=True)
    elif idx==6:
        center(draw,92,'SAFER MOVE',F30,(255,218,89))
        draw_rule_card(draw,['DO NOT PAY','FROM THE CHAT','VERIFY FIRST'],warn_index=0)
    elif idx==7:
        center(draw,92,'VERIFY FIRST',F30,(255,218,89))
        draw_company_check(draw)
    else:
        center(draw,100,'THE RULE',F30,(255,218,89))
        draw_rule_card(draw,['PAY TO','GET PAID?','STOP.'],warn_index=1)
    path.parent.mkdir(parents=True, exist_ok=True)
    img.save(path,quality=95)


def create_scene_images()->list[Path]:
    """Copy premium AI keyframes into the render asset directory.

    Final visual policy for this user: AI images carry the scene; renderer only adds
    subtle motion/crossfades and text-only subtitles. No self-built mockup scenes.
    """
    ASSET_DIR.mkdir(parents=True, exist_ok=True)
    paths=[]
    for i, src in enumerate(AI_SCENE_SOURCES, 1):
        source = Path(src)
        if not source.exists():
            raise FileNotFoundError(source)
        p=ASSET_DIR/f'erf023_ai_scene_{i:02d}.jpg'
        im=Image.open(source).convert('RGB')
        im.save(p,quality=96)
        paths.append(p)
    cols = 3
    thumb_w, thumb_h = 270, 480
    rows = math.ceil(len(paths)/cols)
    sheet=Image.new('RGB',(cols*thumb_w,rows*thumb_h),(16,16,16))
    for i,p in enumerate(paths):
        im=Image.open(p).convert('RGB')
        scale=max(thumb_w/im.width, thumb_h/im.height)
        nw,nh=math.ceil(im.width*scale),math.ceil(im.height*scale)
        im=im.resize((nw,nh),Image.Resampling.LANCZOS)
        im=im.crop(((nw-thumb_w)//2,(nh-thumb_h)//2,(nw+thumb_w)//2,(nh+thumb_h)//2))
        sheet.paste(im,((i%cols)*thumb_w,(i//cols)*thumb_h))
    sheet.save(CONTACT,quality=94)
    return paths


def synthesize_voiceover():
    """Synthesize Chatterbox Taylor voiceover in sentence chunks."""
    VERSION_DIR.mkdir(parents=True, exist_ok=True)
    if AUDIO.exists(): return
    sentences=VOICE_CHUNKS
    chunk_dir=VERSION_DIR/'tts_chunks'; chunk_dir.mkdir(parents=True, exist_ok=True)
    pause=VERSION_DIR/'pause_500ms.wav'
    if not pause.exists():
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','lavfi','-i','anullsrc=r=24000:cl=mono','-t','0.50',str(pause)])
    chunk_paths=[]; chunk_meta=[]
    for idx,sentence in enumerate(sentences,1):
        cp=chunk_dir/f'chunk_{idx:02d}.wav'
        if not cp.exists():
            payload={'text':sentence,'voice_mode':'clone','reference_audio_filename':'Gianna.wav','output_format':'wav','temperature':0.80,'exaggeration':0.52,'cfg_weight':0.50,'seed':17570+idx,'speed_factor':1.0,'language':'en','split_text':False,'chunk_size':240,'stream':False}
            t=time.time(); r=requests.post('http://100.101.173.25:8004/tts',json=payload,timeout=180); elapsed=round(time.time()-t,3)
            if r.ok and r.headers.get('content-type','').startswith('audio/'):
                cp.write_bytes(r.content)
            if not r.ok or not cp.exists():
                raise RuntimeError({'sentence':sentence,'http_status':r.status_code,'content_type':r.headers.get('content-type'),'body':r.text[:500]})
            chunk_meta.append({'sentence':sentence,'path':str(cp),'duration':ffprobe_duration(cp),'elapsed_sec':elapsed,'sha256':sha256(cp)})
        else:
            chunk_meta.append({'sentence':sentence,'path':str(cp),'duration':ffprobe_duration(cp),'sha256':sha256(cp)})
        chunk_paths.append(cp)
    concat=VERSION_DIR/'tts_concat.txt'; lines=[]
    for i,p in enumerate(chunk_paths):
        lines.append(f"file '{p.as_posix()}'")
        if i != len(chunk_paths)-1: lines.append(f"file '{pause.as_posix()}'")
    concat.write_text('\n'.join(lines)+'\n')
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','concat','-safe','0','-i',str(concat),'-c','copy',str(AUDIO)])
    meta={'provider':'chatterbox_http_clone_semantic_chunks','base_url':'http://100.101.173.25:8004','voice_mode':'clone','reference_audio_filename':'Gianna.wav','preset':'Gianna premium female reference clone warm narrator v1','script_sha256':hashlib.sha256(SCRIPT.encode()).hexdigest(),'sentence_count':len(sentences),'inter_sentence_pause_ms':500,'temperature':0.80,'exaggeration':0.52,'cfg_weight':0.50,'chunks':chunk_meta,'success':True,'output_path':str(AUDIO),'duration':ffprobe_duration(AUDIO),'output_sha256':sha256(AUDIO),'created_at':datetime.datetime.now(datetime.timezone.utc).isoformat()}
    (VERSION_DIR/'erf-022_voiceover_chatterbox_gianna_clone_premium_v1.metadata.json').write_text(json.dumps(meta,indent=2),encoding='utf-8')


def force_align_words()->list[dict[str,Any]]:
    if WORDS_JSON.exists():
        data=json.loads(WORDS_JSON.read_text()); return [{'word':w['word'],'start':float(w['start']),'duration':max(0.05,float(w.get('duration',float(w['end'])-float(w['start']))))} for w in data['words']]
    from faster_whisper import WhisperModel
    t=time.time(); model=WhisperModel('base.en', device='cpu', compute_type='int8')
    segments, info=model.transcribe(str(AUDIO), language='en', word_timestamps=True, vad_filter=False, beam_size=5)
    words=[]; segs=[]
    for s in segments:
        seg={'start':s.start,'end':s.end,'text':s.text,'words':[]}
        if s.words:
            for w in s.words:
                if not w.word.strip(): continue
                item={'word':w.word.strip(),'start':float(w.start),'end':float(w.end),'duration':max(0.05,float(w.end-w.start))}
                words.append(item); seg['words'].append(item)
        segs.append(seg)
    WORDS_JSON.write_text(json.dumps({'model':'base.en','language':info.language,'duration':info.duration,'elapsed':round(time.time()-t,3),'word_count':len(words),'segments':segs,'words':words},indent=2),encoding='utf-8')
    return [{'word':w['word'],'start':float(w['start']),'duration':float(w['duration'])} for w in words]


def build_segments(words:list[dict[str,Any]])->list[list[dict[str,Any]]]:
    sentences=[s.strip() for s in re.findall(r'[^.!?]+[.!?]', SCRIPT) if s.strip()]
    def token_count(s): return len(s.replace('—',' ').replace('-',' ').split())
    def balanced(n):
        if n<=12: return [n]
        if n<=15: return [(n+1)//2, n-(n+1)//2]
        parts=[]; rem=n
        while rem>0:
            if rem<=12: parts.append(rem); break
            take=7
            if rem-take<3: take=max(3,rem-3)
            parts.append(take); rem-=take
        return parts
    segments=[]; cursor=0
    for s in sentences:
        sw=words[cursor:cursor+token_count(s)]; cursor+=token_count(s); local=0
        for size in balanced(len(sw)):
            seg=sw[local:local+size]
            if seg: segments.append(seg)
            local+=size
    if cursor < len(words):
        tail=words[cursor:]
        if segments and len(tail)<3 and len(segments[-1])+len(tail)<=8: segments[-1].extend(tail)
        else: segments.append(tail)
    return [s for s in segments if s]


def text_size(draw,text,fnt):
    b=draw.textbbox((0,0),text,font=fnt); return int(b[2]-b[0]), int(b[3]-b[1])


def segment_for_time(t, segments):
    for idx,seg in enumerate(segments):
        start=float(seg[0]['start']); next_start=float(segments[idx+1][0]['start']) if idx+1<len(segments) else float(seg[-1]['start'])+float(seg[-1]['duration'])+0.9
        end=max(float(seg[-1]['start'])+float(seg[-1]['duration'])+0.35,next_start-0.05)
        if start<=t<=end: return seg
    return []


def active_word_index(t, seg):
    for i,w in enumerate(seg):
        if float(w['start']) <= t <= float(w['start'])+float(w['duration'])+0.07: return i
    return max(0,min(len(seg)-1,sum(1 for w in seg if float(w['start'])<=t)-1))


def draw_subtitles(img, seg, active):
    if not seg: return img
    draw=ImageDraw.Draw(img); words=[str(w['word']).strip().upper() for w in seg if str(w['word']).strip()]
    if not words: return img
    max_total=WIDTH-150; gap=26
    active=max(0,min(active,len(words)-1))
    def wrap_lines(words,fnt,max_lines=3):
        lines=[]; cur=[]
        for word in words:
            test=cur+[word]; widths=[text_size(draw,w,fnt)[0] for w in test]
            if cur and sum(widths)+gap*(len(test)-1)>max_total:
                lines.append(cur); cur=[word]
            else: cur=test
        if cur: lines.append(cur)
        if len(lines)>1 and len(lines[-1])==1 and len(lines[-2])>2: lines[-1].insert(0,lines[-2].pop())
        return lines if len(lines)<=max_lines else None
    chosen=None; fnt=font(34,True)
    for size in range(58,28,-2):
        cand=font(size,True); lines=wrap_lines(words,cand)
        if lines and all(sum(text_size(draw,w,cand)[0] for w in line)+gap*(len(line)-1)<=max_total for line in lines):
            chosen=lines; fnt=cand; break
    if chosen is None:
        fnt=font(28,True); a=math.ceil(len(words)/3); chosen=[words[:a],words[a:2*a],words[2*a:]]
    y0={1:1518,2:1458,3:1402}.get(len(chosen),1402); line_h={1:74,2:72,3:66}.get(len(chosen),66)
    gi=0
    for li,line in enumerate(chosen):
        widths=[text_size(draw,w,fnt)[0] for w in line]; total=sum(widths)+gap*(len(line)-1); x=max(42,(WIDTH-total)//2); y=y0+li*line_h
        for word,ww in zip(line,widths):
            fill=(255,214,64) if gi==active else (255,255,255)
            draw.text((x,y),word,font=fnt,fill=fill,stroke_width=5,stroke_fill=(0,0,0))
            x+=ww+gap; gi+=1
    return img


def render_video(words,audio_duration, scene_paths):
    total=sum(SCENE_DURATIONS); target=audio_duration+0.45
    durs=[d/total*target for d in SCENE_DURATIONS]
    scenes=[]; cur=0.0
    for d,p in zip(durs,scene_paths): scenes.append((cur,cur+d,p)); cur+=d
    images=[]
    for p in scene_paths:
        im=Image.open(p).convert('RGB')
        scale=max(WIDTH/im.width, HEIGHT/im.height); nw,nh=math.ceil(im.width*scale), math.ceil(im.height*scale)
        im=im.resize((nw,nh),Image.Resampling.LANCZOS); images.append(im.crop(((nw-WIDTH)//2,(nh-HEIGHT)//2,(nw+WIDTH)//2,(nh+HEIGHT)//2)))
    def scene_idx(t):
        for i,(s,e,_) in enumerate(scenes):
            if s<=t<e: return i
        return len(scenes)-1
    def motion(im,p,idx):
        zoom=1.012+0.024*p; w=int(WIDTH/zoom); h=int(HEIGHT/zoom)
        dx=int(18*math.sin((p+idx*.17)*math.tau)); dy=int(14*math.cos((p+idx*.11)*math.tau))
        left=max(0,min(WIDTH-w,WIDTH//2+dx-w//2)); top=max(0,min(HEIGHT-h,HEIGHT//2+dy-h//2))
        return im.crop((left,top,left+w,top+h)).resize((WIDTH,HEIGHT),Image.Resampling.LANCZOS)
    segments=build_segments(words); total_frames=math.ceil(target*FPS)
    with tempfile.TemporaryDirectory(prefix='erf023_frames_') as tmp:
        fd=Path(tmp)/'frames'; fd.mkdir()
        for n in range(total_frames):
            t=n/FPS; idx=scene_idx(t); s,e,_=scenes[idx]; p=max(0,min(1,(t-s)/max(.001,e-s)))
            frame=motion(images[idx],p,idx)
            if idx>0 and p<0.22:
                frame=Image.blend(motion(images[idx-1],1.0,idx-1),frame,p/0.22)
            seg=segment_for_time(t,segments); frame=draw_subtitles(frame,seg,active_word_index(t,seg) if seg else 0)
            frame.save(fd/f'frame_{n:06d}.jpg',quality=94,subsampling=1)
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-framerate',str(FPS),'-i',str(fd/'frame_%06d.jpg'),'-vf','setsar=1,setdar=9/16,format=yuv420p','-c:v','libx264','-preset','slow','-crf','16','-movflags','+faststart',str(VIDEO_NO_AUDIO)])
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-i',str(VIDEO_NO_AUDIO),'-i',str(AUDIO),'-map','0:v:0','-map','1:a:0','-c:v','copy','-c:a','aac','-b:a','192k','-shortest','-movflags','+faststart',str(FINAL)])
    for sec in QA_SECONDS:
        out=VERSION_DIR/f'qa_frame_{sec:02d}.png'
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-ss',str(sec),'-i',str(FINAL),'-frames:v','1',str(out)])


def probe(path):
    meta=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_entries','stream=width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate','-show_entries','format=duration,size,bit_rate','-of','json',str(path)],text=True))
    s=meta['streams'][0]; f=meta['format']
    return {'width':s['width'],'height':s['height'],'sample_aspect_ratio':s.get('sample_aspect_ratio'),'display_aspect_ratio':s.get('display_aspect_ratio'),'frame_rate':s.get('r_frame_rate'),'duration':float(f['duration']),'size_bytes':int(f['size']),'bit_rate':int(f.get('bit_rate',0))}


def build_package(data):
    PACKAGE_DIR.mkdir(parents=True, exist_ok=True)
    pkg_path=PACKAGE_DIR/f'{CANDIDATE_ID}_{VERSION}_youtube_private_upload_package.json'
    video_sha=data['sha256']
    pkg={
        'candidate_id':CANDIDATE_ID,'version':VERSION,'title':TITLE,'description':DESCRIPTION,
        'hashtags':['#JobScam','#RemoteWork','#ScamAlert','#MoneySafety','#EverydayRedFlags'],
        'tags':['remote job scam','equipment fee scam','job payment scam','pay to work','scam alert','money safety'],
        'language':'en','categoryId':'27','privacyStatus':'private','publishAt':None,
        'selfDeclaredMadeForKids':False,'made_for_kids_reason':'General digital safety content for adults and families, not directed to children.',
        'containsSyntheticMedia':True,'synthetic_media_reason':'AI-assisted/deterministic visual production; Chatterbox synthetic voice; synthetic media disclosed for manual Studio review.',
        'thumbnail_path':None,'thumbnail_sha256':None,'video_path':str(FINAL),'video_sha256':video_sha,
        'claim_gate_result':'valid','safety_gate_result':'valid','upload_allowed':False,
    }
    canonical=json.dumps(pkg,sort_keys=True,separators=(',',':')).encode(); pack_sha=hashlib.sha256(canonical).hexdigest()
    pkg['posting_pack_sha256']=pack_sha
    pkg['approval_command']=f'APPROVED_FOR_PRIVATE_YOUTUBE_UPLOAD {CANDIDATE_ID} {VERSION} {video_sha} {pack_sha}'
    pkg_path.write_text(json.dumps(pkg,indent=2),encoding='utf-8')
    data.update({'title':TITLE,'description':DESCRIPTION,'approval_command':pkg['approval_command'],'posting_pack_sha256':pack_sha,'upload_gate_valid':True,'package_path':str(pkg_path),'thumbnail_in_package':None})
    REVIEW_PACKAGE.write_text(json.dumps(data,indent=2),encoding='utf-8')
    return pkg, pkg_path


def update_topic_db(final_path):
    db=ROOT/'data/content_ideas/everyday_red_flags_topic_database.json'
    if not db.exists(): return
    data=json.loads(db.read_text())
    for cat in data.get('categories',[]):
        for item in cat.get('items',[]):
            if item.get('topic_id') in {'erf-topic-fake-equipment-check'}:
                item['status']='review_ready'; item['priority']='awaiting_review'; item['existing_artifact_hint']='erf-023_fake_equipment_check'
    data['updated_at']=datetime.datetime.now(datetime.timezone.utc).isoformat()
    db.write_text(json.dumps(data,indent=2,ensure_ascii=False),encoding='utf-8')


def export_website_companion_from_review(
    *,
    website_root: Path = DEFAULT_WEBSITE_ROOT,
    companion_spec: Path = DEFAULT_COMPANION_SPEC,
    run_generator: bool = False,
    force: bool = False,
):
    return export_companion_candidate(
        review_package_path=REVIEW_PACKAGE,
        companion_spec_path=companion_spec,
        website_root=website_root,
        run_generator=run_generator,
        force=force,
    )


def build_parser():
    parser = argparse.ArgumentParser(description="Render ERF-022 Fake Equipment Check and optionally prepare the website companion page.")
    parser.add_argument("--prepare-website-companion", action="store_true", help="Export the review package to the TrueTraceShorts website candidate pipeline after rendering.")
    parser.add_argument("--run-website-generator", action="store_true", help="When preparing the website companion, also run the website npm generator.")
    parser.add_argument("--force-website-companion", action="store_true", help="Pass --force to the website generator when --run-website-generator is enabled.")
    parser.add_argument("--website-companion-only", action="store_true", help="Skip rendering and use the existing review_package_chatterbox.json for website companion export.")
    parser.add_argument("--website-root", type=Path, default=DEFAULT_WEBSITE_ROOT)
    parser.add_argument("--companion-spec", type=Path, default=DEFAULT_COMPANION_SPEC)
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    if args.website_companion_only:
        if not REVIEW_PACKAGE.exists():
            raise FileNotFoundError(REVIEW_PACKAGE)
        data = json.loads(REVIEW_PACKAGE.read_text(encoding="utf-8"))
    else:
        VERSION_DIR.mkdir(parents=True, exist_ok=True)
        scene_paths=create_scene_images()
        synthesize_voiceover()
        words=force_align_words(); audio_duration=ffprobe_duration(AUDIO)
        render_video(words,audio_duration,scene_paths)
        segments=build_segments(words)
        media_meta=probe(FINAL); video_sha=sha256(FINAL)
        media_cache_path=MEDIA_CACHE / FINAL.name
        MEDIA_CACHE.mkdir(parents=True, exist_ok=True); shutil.copy2(FINAL,media_cache_path)
        data={'candidate_id':CANDIDATE_ID,'version':VERSION,'script':SCRIPT,'scene_images':[str(p) for p in scene_paths],
              'caption_alignment':'faster_whisper_word_timestamps_source_punctuation_segments','caption_style':'static_phrase_segment_2line_active_word_highlight','word_count':len(words),'segment_count':len(segments),'audio_duration':audio_duration,'video_meta':media_meta,'sha256':video_sha,'output':str(FINAL),'media_cache_path':str(media_cache_path),'media_cache_sha256':sha256(media_cache_path),'audio':str(AUDIO),'contact_sheet':str(CONTACT),'qa_frames':[str(VERSION_DIR/f'qa_frame_{s:02d}.png') for s in QA_SECONDS],
              'visual_policy':'premium AI-generated keyframes as main visual layer; first frame has in-screen red scam name for YouTube gallery consistency; renderer adds only subtle motion/crossfades and text-only active-word subtitles','notes':'Chatterbox Gianna reference-clone voiceover via Tailscale endpoint; forced word timing, readable sentence-level caption groups, remote job equipment-fee scam visuals; repaired after mockup-style rejection using high-quality prompted AI keyframes; no self-built mockup/PowerPoint main visuals; thumbnail intentionally omitted; user sets thumbnail manually.'}
        build_package(data)
        update_topic_db(FINAL)

    if args.prepare_website_companion or args.run_website_generator or args.website_companion_only:
        plan = export_website_companion_from_review(
            website_root=args.website_root,
            companion_spec=args.companion_spec,
            run_generator=args.run_website_generator,
            force=args.force_website_companion,
        )
        data['website_companion'] = plan.to_dict()
        REVIEW_PACKAGE.write_text(json.dumps(data, indent=2), encoding="utf-8")
    return data

if __name__ == '__main__':
    print(json.dumps(main(),indent=2))
