#!/usr/bin/env python3
"""Render TrueTrace format test 001: already clicked fake delivery link recovery guide.

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" / "truetrace-already-clicked-fake-delivery-link"
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 / "tt001_voiceover_chatterbox_gianna_clone_premium_v1.wav"
WORDS_JSON = VERSION_DIR / "tt001_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 / "tt001_already_clicked_fake_delivery_link_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" / "tt001-already-clicked-fake-delivery-link.json"
REVIEW_PACKAGE = VERSION_DIR / "review_package_chatterbox.json"
WIDTH, HEIGHT, FPS = 1080, 1920, 30
CANDIDATE_ID = "tt001-already-clicked-fake-delivery-link"
QA_SECONDS = (1, 8, 16, 26, 38, 52)
AI_SCENE_SOURCES = [
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_104140_4264a641.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_103937_f08e7167.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_104034_50194091.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_104237_6b6218ff.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_104330_24221b37.png",
    "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_104434_555af0c3.png",
]
TITLE = "Already Clicked a Fake Delivery Link?"
DESCRIPTION = "Clicked a suspicious delivery link? Stop the next action first.\n\nIf you entered nothing, close it and open the real delivery app or shop order page yourself. If you entered card details, contact your bank or card provider through the official app or saved number.\n\nYou didn’t do anything wrong. These scams are designed to make you hurry. The safer move is to slow down and use official channels.\n\nFull checklist: https://truetraceshorts.com/already-clicked/\n\n#ScamRecovery #DeliveryScam #Phishing #OnlineSafety #EverydayRedFlags"
SCRIPT = (
    "Already clicked a fake delivery link? Stop here first. "
    "First, do not enter anything else, do not approve anything, and do not call a number from that page. "
    "You did not do anything wrong. This is designed to make you hurry. "
    "If you entered nothing, close the page and open the real delivery app, or the shop order page, yourself. "
    "If you entered card details, contact your bank or card provider from the official app or saved number. "
    "Ask about blocking the card, watching for small test charges, and stopping unfamiliar subscriptions. "
    "If you entered a password, change it from the real website and sign out of other sessions. "
    "Do not pay a second fee to fix the first one. Do not trust recovery messages that arrive right after. "
    "Keep the message, the time, and screenshots if it is safe. "
    "Then use the full recovery checklist on TrueTraceShorts. Rule: stop the next action first."
)
VOICE_CHUNKS = [
    "Already clicked a fake delivery link? Stop here first.",
    "First, do not enter anything else, do not approve anything, and do not call a number from that page.",
    "You did not do anything wrong. This is designed to make you hurry.",
    "If you entered nothing, close the page and open the real delivery app, or the shop order page, yourself.",
    "If you entered card details, contact your bank or card provider from the official app or saved number.",
    "Ask about blocking the card, watching for small test charges, and stopping unfamiliar subscriptions.",
    "If you entered a password, change it from the real website and sign out of other sessions.",
    "Do not pay a second fee to fix the first one. Do not trust recovery messages that arrive right after.",
    "Keep the message, the time, and screenshots if it is safe.",
    "Then use the full recovery checklist on TrueTraceShorts. Rule: stop the next action first.",
]
SCENE_DURATIONS = [5.8, 8.0, 6.5, 8.5, 8.5, 9.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'tt001_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':18570+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/'tt001_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='tt001_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':['#ScamRecovery','#DeliveryScam','#Phishing','#OnlineSafety','#EverydayRedFlags'],
        'tags':['already clicked fake delivery link','fake delivery link','delivery scam recovery','phishing recovery','card details scam','online 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,'format_type':'already_clicked','hook_type':'recovery','viewer_state':'already_clicked','cta_type':'checklist','duration_class':'50-75','website_target':'/already-clicked/','first_frame_scam_label':'FAKE DELIVERY LINK',
    }
    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 TrueTrace format test 001 already-clicked fake delivery link recovery guide.")
    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 visible frame only has in-screen red scam name for YouTube gallery consistency; renderer adds only subtle motion/crossfades and text-only active-word subtitles','format_metadata':{'format_type':'already_clicked','hook_type':'recovery','viewer_state':'already_clicked','website_target':'/already-clicked/','cta_type':'checklist','duration_class':'50-75','first_frame_scam_label':'FAKE DELIVERY LINK','evidence_level':'generic_pattern','claim_mode':'educational_pattern'},'notes':'Chatterbox Gianna reference-clone premium voiceover via Tailscale endpoint; forced word timing; already-clicked fake delivery link recovery format; no-shame tone; no self-built mockup/PowerPoint main visuals; scam label appears only on first visible keyframe; 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))
