#!/usr/bin/env python3
"""Render Everyday Red Flags 024: fake page deletion warning creator trap.

Policy: premium AI-generated keyframes + text-only active-word subtitles.
No renderer-built UI/mockup scenes, arrows, boxes, circles, progress bars, cards, labels, or caption boxes.
"""
from __future__ import annotations
import hashlib, json, math, subprocess, tempfile, time, datetime
from pathlib import Path
from typing import Any
import requests
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "data" / "post_candidates" / "everyday-red-flags-fake-page-deletion-warning"
VERSION = "chatterbox_gianna_clone_premium_v1_readable_silent_2line_clean_ai"
VERSION_DIR = OUT_DIR / VERSION
AUDIO = VERSION_DIR / "erf024_voiceover_chatterbox_gianna_clone_premium_v1.wav"
RAW_AUDIO = VERSION_DIR / "erf024_voiceover_chatterbox_gianna_clone_premium_v1_raw.wav"
WORDS_JSON = VERSION_DIR / "erf024_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 / "erf024_fake_page_deletion_warning_chatterbox_gianna_clone_premium_v1_1080x1920_review.mp4"
CONTACT = VERSION_DIR / "contact_sheet_keyframes.png"
QA_SECONDS = (1, 6, 12, 20, 28, 35)
QA_FRAMES = [VERSION_DIR / f"qa_frame_{sec:02d}.png" for sec in QA_SECONDS]
WIDTH, HEIGHT, FPS = 1080, 1920, 30
CANDIDATE_ID = "erf-024-fake-page-deletion-warning"
FORMAT_FAMILY = "EVERYDAY_RED_FLAG"
SCAM_TYPE_ID = "fake_page_deletion_warning"
WEBSITE_SLUG = "fake-page-deletion-warning"
TITLE = "Your Page Will Be Deleted? Don’t Click Yet"
DESCRIPTION = "A fake platform warning can look scary, especially when it says your page will be deleted soon.\n\nThe red flag is the appeal link inside the message. Real account alerts should still be visible when you open the platform yourself.\n\nSafer move: close the message, open the app or website from your normal icon or bookmark, and check account alerts there.\n\nChecklist: https://truetraceshorts.pages.dev/redflags/fake-page-deletion-warning/\n\n#CreatorSafety #ScamAlert #OnlineSafety #Phishing #EverydayRedFlags"
TIKTOK_CAPTION = "Fake page-deletion warning? Don’t tap the appeal link. Open the app yourself and check account alerts there. #ScamAlert #CreatorSafety #OnlineSafety #Phishing"
SCRIPT = (
    "If your page gets this warning, do not click the appeal link. "
    "It says your page may be deleted soon, and that pressure is the trap. "
    "The red flag is not just the warning. It is the link that asks you to appeal or log in from the message. "
    "A real platform alert should still be there when you open the app yourself. "
    "Safer move: close the message. Open the platform from the normal app icon or your own bookmark. "
    "Then check account alerts inside the real settings area. "
    "If nothing shows there, the warning was probably bait. "
    "Has someone tried this on your page or account?"
)
SCENES = [
    (0.0, 6.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_202241_9232beef.png"),
    (6.0, 12.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_202506_b3493eeb.png"),
    (12.0, 20.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_202709_59eec5a4.png"),
    (20.0, 27.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_202917_05cff9c9.png"),
    (27.0, 33.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_203033_0b0f20df.png"),
    (33.0, 41.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260605_203527_ecc71281.png"),
]

def font(size:int,bold:bool=False):
    for c in ["/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"]:
        if Path(c).exists(): return ImageFont.truetype(c,size=size)
    return ImageFont.load_default()

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

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 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 synthesize_chatterbox()->None:
    VERSION_DIR.mkdir(parents=True, exist_ok=True)
    if AUDIO.exists(): return
    # Render sentence-by-sentence and insert small pauses so silent viewers can read
    # complete caption ideas without the text racing like a caffeinated lawyer.
    import re
    sentences=[s.strip() for s in re.findall(r'[^.!?]+[.!?]', SCRIPT) if s.strip()]
    chunk_dir=VERSION_DIR/'tts_chunks'; chunk_dir.mkdir(parents=True, exist_ok=True)
    chunk_paths=[]; chunk_meta=[]
    for idx,sentence in enumerate(sentences, start=1):
        chunk_path=chunk_dir/f'chunk_{idx:02d}.wav'
        if not chunk_path.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':30500+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/'):
                chunk_path.write_bytes(r.content)
            if not r.ok or not chunk_path.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(chunk_path),'duration':ffprobe_duration(chunk_path),'elapsed_sec':elapsed,'sha256':sha256(chunk_path)})
        else:
            chunk_meta.append({'sentence':sentence,'path':str(chunk_path),'duration':ffprobe_duration(chunk_path),'sha256':sha256(chunk_path)})
        chunk_paths.append(chunk_path)
    silence=VERSION_DIR/'pause_800ms.wav'
    if not silence.exists():
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','lavfi','-i','anullsrc=r=24000:cl=mono','-t','0.80',str(silence)])
    concat_file=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 '{silence.as_posix()}'")
    concat_file.write_text('\n'.join(lines)+'\n',encoding='utf-8')
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','concat','-safe','0','-i',str(concat_file),'-c','copy',str(RAW_AUDIO)])
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-i',str(RAW_AUDIO),'-af','loudnorm=I=-18:TP=-1.5:LRA=7','-ar','24000','-ac','1',str(AUDIO)])
    meta={'provider':'chatterbox_http_clone_semantic_chunks','voice_mode':'clone','reference_audio_filename':'Gianna.wav','preset':'Gianna premium female reference clone warm narrator v1','endpoint':'http://100.101.173.25:8004/tts','script_sha256':hashlib.sha256(SCRIPT.encode()).hexdigest(),'sentence_count':len(sentences),'inter_sentence_pause_ms':800,'tts_parameters':{'temperature':0.80,'exaggeration':0.52,'cfg_weight':0.50,'speed_factor':1.0},'normalization':{'filter':'loudnorm','integrated_lufs_target':-18,'true_peak_target_db':-1.5,'lra_target':7},'chunks':chunk_meta,'success':True,'raw_output_path':str(RAW_AUDIO),'raw_output_sha256':sha256(RAW_AUDIO),'output_path':str(AUDIO),'duration':ffprobe_duration(AUDIO),'output_sha256':sha256(AUDIO),'created_at':datetime.datetime.now(datetime.timezone.utc).isoformat()}
    (VERSION_DIR/'erf024_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]]]:
    # Sentence-first, silent-viewer-friendly caption groups.
    # Build segment boundaries from the written script, then map them onto ASR words.
    # This prevents sentence splices and orphan words such as one final word on a new page.
    import re
    sentences=[s.strip() for s in re.findall(r'[^.!?]+[.!?]', SCRIPT) if s.strip()]
    def token_count(sentence:str)->int:
        # Match Whisper behaviour for hyphenated words like pop-up: usually two word cues.
        toks=sentence.replace('—',' ').replace('-',' ').replace('popup','pop up').replace('Popup','Pop up').split()
        return len(toks)
    def balanced_sizes(n:int)->list[int]:
        if n <= 0: return []
        # For silent comprehension, keep short sentences complete even if the
        # font shrinks slightly. A complete 9-10 word idea is better than an
        # incomplete page such as 'DO NOT CALL THE'.
        if n <= 11: return [n]
        if n <= 14:
            a=(n+1)//2
            return [a, n-a]
        # Prefer 6-8 word blocks, never leave <3 word tail.
        parts=[]; remaining=n
        while remaining > 0:
            if remaining <= 11:
                parts.append(remaining); break
            take=7
            if remaining - take < 3:
                take=max(3, remaining-3)
            parts.append(take); remaining-=take
        return parts
    segments=[]; cursor=0
    for sentence in sentences:
        n=token_count(sentence)
        sentence_words=words[cursor:cursor+n]
        cursor += n
        local=0
        for size in balanced_sizes(len(sentence_words)):
            seg=sentence_words[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)
        elif tail:
            segments.append(tail)
    # ASR tokenisation can drift by one word versus the written script. If there is
    # a clear sentence pause, do not let the first word of the next sentence hang
    # on the previous caption page. This protects CTA starts such as
    # "Has a buyer..." from becoming "walk away. Has".
    repaired=[]
    for seg in segments:
        current=[]
        for word in seg:
            if current:
                prev=current[-1]
                prev_end=float(prev['start'])+float(prev['duration'])
                gap=float(word['start'])-prev_end
                if gap >= 0.55 and str(prev['word']).rstrip().endswith(('.', '?', '!')):
                    repaired.append(current)
                    current=[]
            current.append(word)
        if current:
            repaired.append(current)
    return [seg for seg in repaired if len(seg) >= 1]

def segment_for_time(t:float, segments):
    for seg in segments:
        start=float(seg[0]['start'])
        if t < start: continue
        idx=segments.index(seg)
        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:float, seg)->int:
    for i,w in enumerate(seg):
        start=float(w['start']); end=start+float(w['duration'])+0.07
        if start <= t <= end: return i
    return max(0, min(len(seg)-1, sum(1 for w in seg if float(w['start']) <= t)-1))

def load_images()->list[Image.Image]:
    out=[]
    for _,_,p in SCENES:
        path=Path(p)
        if not path.exists(): raise FileNotFoundError(path)
        img=Image.open(path).convert('RGB')
        scale=max(WIDTH/img.width, HEIGHT/img.height); new=(math.ceil(img.width*scale), math.ceil(img.height*scale))
        img=img.resize(new, Image.Resampling.LANCZOS); left=(img.width-WIDTH)//2; top=(img.height-HEIGHT)//2
        out.append(img.crop((left,top,left+WIDTH,top+HEIGHT)))
    return out

def scene_index(t:float)->int:
    for i,(s,e,_) in enumerate(SCENES):
        if s <= t < e: return i
    return len(SCENES)-1

def local_progress(t:float, idx:int)->float:
    s,e,_=SCENES[idx]; return max(0.0,min(1.0,(t-s)/max(0.001,e-s)))

def motion(img:Image.Image,p:float,idx:int)->Image.Image:
    zoom=1.015+0.025*p; w=int(WIDTH/zoom); h=int(HEIGHT/zoom)
    dx=int(24*math.sin((p+idx*0.19)*math.tau)*0.5); dy=int(18*math.cos((p+idx*0.13)*math.tau)*0.5)
    left=max(0,min(WIDTH-w,WIDTH//2+dx-w//2)); top=max(0,min(HEIGHT-h,HEIGHT//2+dy-h//2))
    return img.crop((left,top,left+w,top+h)).resize((WIDTH,HEIGHT), Image.Resampling.LANCZOS)

def crossfade(frame:Image.Image, images:list[Image.Image], t:float, idx:int, p:float)->Image.Image:
    if idx>0 and p<0.25:
        start,_,_=SCENES[idx]; a=max(0.0,min(1.0,(t-start)/0.25)); prev=motion(images[idx-1],1.0,idx-1); return Image.blend(prev,frame,a)
    return frame

def draw_subtitles(img:Image.Image, seg, active:int)->Image.Image:
    if not seg: return img
    draw=ImageDraw.Draw(img)
    units=[]
    for idx,w in enumerate(seg):
        raw=str(w['word']).strip().upper()
        if not raw: continue
        cleaned=raw.replace(' ', '')
        if units and cleaned in {'-UP','UP'} and units[-1][0].replace(' ', '') == 'POP':
            units[-1]=(units[-1][0]+'-UP', units[-1][1]+[idx])
        elif units and cleaned.startswith('-') and len(cleaned) <= 4:
            units[-1]=(units[-1][0]+cleaned, units[-1][1]+[idx])
        else:
            units.append((raw,[idx]))
    words=[u[0] for u in units]
    if not words: return img
    active_unit=0
    for ui,(_,orig) in enumerate(units):
        if active in orig:
            active_unit=ui; break
    max_total=WIDTH-190; gap=24; fnt=font(58,True)
    def wrap_for_font(cand, max_lines=3):
        lines=[]; cur=[]
        for word in words:
            test=cur+[word]
            widths=[text_size(draw,w,cand)[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
    lines=None
    for size in range(58,32,-2):
        cand=font(size,True)
        candidate=wrap_for_font(cand, max_lines=3)
        if candidate:
            lines=candidate; fnt=cand; break
    if lines is None:
        fnt=font(32,True)
        chunk=math.ceil(len(words)/3)
        lines=[words[:chunk], words[chunk:2*chunk], words[2*chunk:]]
    y0={1:1518,2:1458,3:1396}.get(len(lines),1396); line_h={1:74,2:72,3:66}.get(len(lines),66); gi=0
    for li,line in enumerate(lines):
        widths=[text_size(draw,word,fnt)[0] for word in line]; total=sum(widths)+gap*(len(line)-1); x=(WIDTH-total)//2; y=y0+li*line_h
        for word,ww in zip(line,widths):
            fill=(255,214,64) if gi==active_unit 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 make_contact_sheet(images:list[Image.Image])->None:
    sheet=Image.new('RGB',(810,960),(16,16,16))
    for i,img in enumerate(images): sheet.paste(img.resize((270,480),Image.Resampling.LANCZOS),((i%3)*270,(i//3)*480))
    sheet.save(CONTACT,quality=94)

def render(words:list[dict[str,Any]], audio_duration:float)->None:
    global SCENES
    old_total=SCENES[-1][1]; scene_total=audio_duration+0.45; SCENES=[(s/old_total*scene_total,e/old_total*scene_total,p) for s,e,p in SCENES]
    images=load_images(); make_contact_sheet(images); segments=build_segments(words)
    total_duration=max(audio_duration+0.45, SCENES[-1][1]); total_frames=math.ceil(total_duration*FPS)
    with tempfile.TemporaryDirectory(prefix='erf024_frames_') as tmp:
        frame_dir=Path(tmp)/'frames'; frame_dir.mkdir()
        for n in range(total_frames):
            t=n/FPS; idx=scene_index(t); p=local_progress(t,idx); frame=motion(images[idx],p,idx); frame=frame
            seg=segment_for_time(t,segments); frame=draw_subtitles(frame,seg,active_word_index(t,seg) if seg else 0)
            frame.save(frame_dir/f'frame_{n:06d}.jpg',quality=94,subsampling=1)
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-framerate',str(FPS),'-i',str(frame_dir/'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,out in zip(QA_SECONDS,QA_FRAMES): subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-ss',str(sec),'-i',str(FINAL),'-frames:v','1',str(out)])

def probe(path:Path)->dict[str,Any]:
    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))}

PACKAGE_DIR = VERSION_DIR / 'youtube_private_upload_package'
REVIEW_PACKAGE = VERSION_DIR / 'review_package_chatterbox.json'
MEDIA_CACHE = Path.home() / '.hermes' / 'media_cache' / 'autoshorts'


def build_package(data:dict[str,Any])->None:
    PACKAGE_DIR.mkdir(parents=True, exist_ok=True)
    video_sha=data['sha256']
    pkg={
        'candidate_id':CANDIDATE_ID,'version':VERSION,'title':TITLE,'description':DESCRIPTION,
        'hashtags':['#CreatorSafety','#ScamAlert','#OnlineSafety','#Phishing','#EverydayRedFlags'],
        'tags':['page deletion scam','creator account security','phishing warning','online safety','social media scam'],
        '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-generated premium keyframes and 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':'red_flag_short','hook_type':'direct_warning','viewer_state':'creator_under_pressure','cta_type':'experience_question','duration_class':'35-45','website_target':'/redflags/fake-page-deletion-warning/','first_frame_scam_label':'PAGE DELETION WARNING',
    }
    pack_sha=hashlib.sha256(json.dumps(pkg,sort_keys=True,separators=(',',':')).encode()).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=PACKAGE_DIR/f'{CANDIDATE_ID}_{VERSION}_youtube_private_upload_package.json'
    pkg_path.write_text(json.dumps(pkg,indent=2),encoding='utf-8')
    data.update({'title':TITLE,'description':DESCRIPTION,'tiktok_caption':TIKTOK_CAPTION,'approval_command':pkg['approval_command'],'posting_pack_sha256':pack_sha,'upload_gate_valid':True,'package_path':str(pkg_path)})
    REVIEW_PACKAGE.write_text(json.dumps(data,indent=2),encoding='utf-8')

def main()->dict[str,Any]:
    OUT_DIR.mkdir(parents=True, exist_ok=True); VERSION_DIR.mkdir(parents=True, exist_ok=True)
    synthesize_chatterbox(); words=force_align_words(); audio_duration=ffprobe_duration(AUDIO); render(words,audio_duration)
    MEDIA_CACHE.mkdir(parents=True, exist_ok=True)
    media_cache_path=MEDIA_CACHE/FINAL.name
    import shutil; shutil.copy2(FINAL,media_cache_path)
    segments=build_segments(words); data={'candidate_id':CANDIDATE_ID,'version':VERSION,'script':SCRIPT,'scene_images':[p for _,_,p in SCENES],'existing_asset_check':{'checked':True,'script':None,'renderer':'scripts/render_erf024_fake_page_deletion_warning.py','legacy_assets':[],'selected_source_of_truth':'data/content_ideas/everyday_red_flags_topic_database.json:erf-topic-fake-your-page-will-be-deleted'},'caption_alignment':'faster_whisper_word_timestamps_source_punctuation_segments','caption_style':'static_phrase_segment_2line_active_word_highlight_no_box_no_frame','word_count':len(words),'segment_count':len(segments),'audio_duration':audio_duration,'video_meta':probe(FINAL),'sha256':sha256(FINAL),'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(p) for p in QA_FRAMES if p.exists()],'visual_policy':'premium AI-generated keyframes as final main visuals; renderer only crops/motions keyframes and adds text-only active-word subtitles; no self-built UI/mockup scenes','format_metadata':{'format_family':FORMAT_FAMILY,'format_type':'red_flag_short','scam_type_id':SCAM_TYPE_ID,'hook_type':'direct_warning','viewer_state':'creator_under_pressure','website_target':'/redflags/fake-page-deletion-warning/','cta_type':'experience_question','duration_class':'30-45','first_frame_scam_label':'PAGE DELETION WARNING','evidence_level':'generic_pattern','claim_mode':'educational_pattern'},'review_package_gates':{'one_second_recognition':True,'open_loop':True,'one_red_flag':'appeal/login link inside an urgent deletion warning','one_safer_move':'close the message and open the platform from the normal app or bookmark','website_cta':True,'safety_no_real_data':True,'caption_style_locked':True},'notes':'Everyday Red Flag short using Gianna premium clone voice and premium AI keyframes. Topic selected from social media platform traps; inspected only as topic reference; not reused as release visual/voice source.'}
    build_package(data)
    return data
if __name__ == '__main__': print(json.dumps(main(),indent=2))
