#!/usr/bin/env python3
"""Render Everyday Red Flags 022: fake CAPTCHA notification trap 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-captcha"
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-022_voiceover_chatterbox_gianna_clone_premium_v1.wav"
WORDS_JSON = VERSION_DIR / "erf-022_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-022_fake_captcha_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-022-fake-captcha.json"
REVIEW_PACKAGE = VERSION_DIR / "review_package_chatterbox.json"
WIDTH, HEIGHT, FPS = 1080, 1920, 30
CANDIDATE_ID = "erf-022-fake-captcha"
QA_SECONDS = (1, 7, 14, 21, 27, 33)
AI_SCENE_SOURCES = [
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_233543_4805f42e.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_235116_df32e1fe.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_235217_02d0a5a8.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_233711_f073c015.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_233427_bde021ce.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_235414_1598dfad.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_233757_d5403a6f.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260604_235304_d1f910b7.png",
]
TITLE = "Fake CAPTCHA Trap? Don’t Click Allow"
DESCRIPTION = "A fake CAPTCHA can look like a normal human check, but one permission request gives it away.\n\nThe red flag is the Allow button: real CAPTCHAs do not need browser notification access.\n\nSafer move: click Block, close the tab, and remove the site from notification settings if you already allowed it.\n\nRule: CAPTCHA plus notification permission means stop.\n\n#FakeCaptcha #BrowserSafety #ScamAlert #CyberSafety #EverydayRedFlags"
SCRIPT = (
    "If a CAPTCHA asks you to click Allow, stop. "
    "A real CAPTCHA checks a box, image, or puzzle. "
    "It does not need permission to send notifications. "
    "Fake CAPTCHA pages use the same trick: prove you are human, press Allow, continue. "
    "But Allow can let the site push scam alerts to your screen. "
    "Then you may see fake virus warnings, prize messages, or account alerts. "
    "The safer move is simple: click Block and close the tab. "
    "If you already allowed it, remove that site from browser notification settings. "
    "Rule: CAPTCHA plus notification permission means stop."
)
VOICE_CHUNKS = [
    "If a CAPTCHA asks you to click Allow, stop.",
    "A real CAPTCHA checks a box, image, or puzzle.",
    "It does not need permission to send notifications.",
    "Fake CAPTCHA pages use the same trick: prove you are human, press Allow, continue.",
    "But Allow can let the site push scam alerts to your screen.",
    "Then you may see fake virus warnings, prize messages, or account alerts.",
    "The safer move is simple: click Block and close the tab.",
    "If you already allowed it, remove that site from browser notification settings.",
    "Rule: CAPTCHA plus notification permission means stop.",
]
SCENE_DURATIONS = [3.6, 3.6, 3.0, 5.7, 3.9, 4.6, 3.2, 8.7]


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_phone_email(draw, x=96, y=186, w=888, h=1180, changed=False, confirm=False):
    draw_round(draw,(x,y,x+w,y+h),58,(18,22,34),(60,70,96),2)
    draw_round(draw,(x+30,y+60,x+w-30,y+h-54),34,(238,242,248))
    draw.text((x+70,y+95),'Mail',font=F20,fill=(25,31,45))
    draw.text((x+w-230,y+104),'Today 09:18',font=F12,fill=(95,104,122))
    draw.line((x+55,y+170,x+w-55,y+170),fill=(210,216,226),width=2)
    draw.text((x+70,y+210),'From: billing@northgate-supply.example',font=F14,fill=(42,52,70))
    draw.text((x+70,y+270),'Subject: Invoice 4821 - payment details',font=F14,fill=(42,52,70))
    draw_round(draw,(x+70,y+350,x+w-70,y+970),28,(255,255,255),(220,226,236),2)
    draw.text((x+110,y+400),'Invoice 4821',font=F24,fill=(25,35,55))
    draw.text((x+110,y+480),'Amount due: 1,240.00',font=F18,fill=(35,44,64))
    draw.text((x+110,y+555),'Due date: Friday',font=F18,fill=(35,44,64))
    if changed:
        draw_round(draw,(x+110,y+650,x+w-110,y+790),18,(255,246,222),(230,185,80),2)
        draw.text((x+140,y+675),'Please use our NEW bank account',font=F16,fill=(96,58,8))
        draw.text((x+140,y+732),'for this payment.',font=F16,fill=(96,58,8))
    else:
        draw.text((x+110,y+660),'Bank details: as usual',font=F16,fill=(70,82,106))
    if confirm:
        draw_round(draw,(x+110,y+830,x+w-110,y+930),18,(226,246,235),(77,159,104),2)
        draw.text((x+140,y+858),'Second channel confirmed',font=F16,fill=(23,100,58))
    draw.text((x+70,y+1015),'Reply  •  Forward  •  Archive',font=F14,fill=(96,106,126))


def draw_contact_card(draw, x=120, y=420, w=840, h=620):
    draw_round(draw,(x,y,x+w,y+h),36,(235,241,248),(75,100,145),2)
    draw.text((x+55,y+55),'Trusted contact',font=F24,fill=(22,34,55))
    draw.text((x+55,y+150),'Northgate Supply Ltd.',font=F20,fill=(35,48,70))
    draw.text((x+55,y+240),'Saved phone: +44 20 5550 0184',font=F16,fill=(54,70,96))
    draw.text((x+55,y+320),'Saved email: accounts@northgate.example',font=F14,fill=(54,70,96))
    draw_round(draw,(x+55,y+430,x+w-55,y+535),28,(33,132,85))
    line1='Confirm bank change here'
    line2='not in the email'
    for j,line in enumerate((line1,line2)):
        b=draw.textbbox((0,0),line,font=F14)
        draw.text((x+(w-(b[2]-b[0]))//2,y+448+j*42),line,font=F14,fill=(255,255,255))


def make_scene(idx:int, path:Path):
    img=background([(40,84,160),(100,64,170),(22,112,100),(128,74,34),(50,102,160),(54,130,92)][idx-1])
    draw=ImageDraw.Draw(img)
    if idx==1:
        center(draw,78,'EVERYDAY RED FLAGS',F18,(196,211,255))
        draw_phone_email(draw, changed=True)
        center(draw,1460,'A NORMAL INVOICE EMAIL',F24)
        center(draw,1536,'WITH ONE QUIET CHANGE',F30,(255,218,89))
    elif idx==2:
        center(draw,92,'THE RED FLAG',F30,(255,218,89))
        draw_phone_email(draw, changed=True)
        # red flag is inside the mockup itself; no external arrow/box.
    elif idx==3:
        center(draw,92,'WHY IT WORKS',F30,(255,218,89))
        draw_round(draw,(125,360,955,1130),42,(238,242,248),(72,84,120),2)
        draw.text((190,435),'Real invoice',font=F24,fill=(26,36,58))
        draw.text((190,540),'Real amount',font=F24,fill=(26,36,58))
        draw.text((190,645),'Real deadline',font=F24,fill=(26,36,58))
        draw.text((190,790),'Fake bank account',font=F30,fill=(150,60,28))
        for yy in (505,610,715): draw.line((190,yy,890,yy),fill=(205,214,230),width=2)
        center(draw,1240,'THE EMAIL FEELS ROUTINE',F24)
        center(draw,1316,'SO THE CHANGE GETS MISSED',F24,(255,218,89))
    elif idx==4:
        center(draw,92,'SAFER MOVE',F30,(255,218,89))
        draw_contact_card(draw)
        center(draw,1165,'USE A TRUSTED CONTACT',F24)
        center(draw,1242,'NOT THE MESSAGE',F24,(255,218,89))
    elif idx==5:
        center(draw,92,'SECOND CHANNEL CHECK',F30,(255,218,89))
        draw_phone_email(draw, changed=True, confirm=True)
        center(draw,1460,'CALL. CONFIRM.',F30,(255,218,89))
        center(draw,1545,'THEN PAY.',F30,(255,218,89))
    else:
        center(draw,100,'THE RULE',F30,(255,218,89))
        draw_round(draw,(110,430,970,1110),48,(238,242,248),(94,113,150),2)
        for i,line in enumerate(['NEW BANK DETAILS','NEED A','SECOND CHECK']):
            b=draw.textbbox((0,0),line,font=F30); draw.text(((WIDTH-(b[2]-b[0]))//2,555+i*150),line,font=F30,fill=(25,36,60) if i!=1 else (150,60,28))
        center(draw,1265,'SEND THIS TO SOMEONE',F24)
        center(draw,1340,'WHO PAYS INVOICES',F24,(255,218,89))
    # lower area remains clean/dark-ish behind subtitles
    path.parent.mkdir(parents=True, exist_ok=True)
    img.save(path,quality=95)


def create_scene_images()->list[Path]:
    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'erf022_ai_scene_{i:02d}.jpg'
        im=Image.open(source).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)
        im=im.crop(((nw-WIDTH)//2,(nh-HEIGHT)//2,(nw+WIDTH)//2,(nh+HEIGHT)//2))
        im.save(p,quality=95)
        paths.append(p)
    sheet=Image.new('RGB',(810,960),(16,16,16))
    for i,p in enumerate(paths):
        im=Image.open(p).convert('RGB').resize((270,480),Image.Resampling.LANCZOS)
        sheet.paste(im,((i%3)*270,(i//3)*480))
    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='erf022_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':['#FakeCaptcha','#BrowserSafety','#ScamAlert','#CyberSafety','#EverydayRedFlags'],
        'tags':['fake CAPTCHA','browser notification scam','permission scam','scam alert','cyber 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-captcha'}:
                item['status']='review_ready'; item['priority']='awaiting_review'; item['existing_artifact_hint']='erf-022_fake_captcha'
    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 CAPTCHA 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':'high-quality AI-generated keyframes + subtitles only; no renderer arrows/boxes/circles/progress bars/labels/caption boxes','notes':'Chatterbox Gianna reference-clone voiceover via Tailscale endpoint; forced word timing, readable sentence-level caption groups, varied fake CAPTCHA / notification permission visuals; first frame includes visible in-image title Fake CAPTCHA Trap; Gianna premium female reference clone voice, no non-subtitle renderer overlays; 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))
