Explanation:
**Creating a video with captions**

I need to create a video in 540x960 resolution at 30 fps, aiming for around 38 seconds. The audio should be about 34.97 seconds, leaving 3 seconds at the end. I’m thinking about burning captions into the video using my own drawing with PIL for the active words, instead of using ASS. I’ll write cues in JSON and SRT formats, generating captions directly on the frames. Then I can merge the audio with the video using ffmpeg.
**Generating video visuals and captions**

I want to use renderer-owned visuals like split panels, cards, arrows, and stamps, while ensuring "PREVIEW / NOT FINAL" is visible. I may generate an ASS file for reference, but since the video will have burned captions, it could be optional. I'll use PIL with DejaVuSans for fonts, checking system fonts for text wrap. I'll execute a script to generate the video with cv2 and PIL, using time-bound scenes, producing an MP4 output and creating subtitles with word cues. Let's get to executing!

from hermes_tools import terminal
from pathlib import Path
import json, re, math, subprocess, os, hashlib
from PIL import Image, ImageDraw, ImageFont
import cv2
import numpy as np

OUT=Path('/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s')
OUT.mkdir(parents=True, exist_ok=True)
AUDIO=OUT/'voiceover.mp3'
SRT=OUT/'edge_subtitles.srt'
SILENT=OUT/'preview_silent.mp4'
MP4=OUT/'preview_ai-output-autopsy-decision-summary-38s_v2.mp4'
WORD_JSON=OUT/'word_cues_time_based.json'
WORD_SRT=OUT/'word_cues_time_based.srt'
META=OUT/'preview_metadata.json'
W,H,FPS=540,960,30
DURATION=38.0

# Parse edge sentence cues
srt_text=SRT.read_text()
blocks=[b.strip() for b in re.split(r'\n\s*\n', srt_text) if b.strip()]
def parse_ts(ts):
    h,m,rest=ts.split(':'); s,ms=rest.split(',')
    return int(h)*3600+int(m)*60+int(s)+int(ms)/1000
sentence_cues=[]
for b in blocks:
    lines=b.splitlines()
    if len(lines)>=3 and '-->' in lines[1]:
        a,bts=[x.strip() for x in lines[1].split('-->')]
        sentence_cues.append((parse_ts(a), parse_ts(bts), ' '.join(lines[2:])))

# distribute word timings within sentence cues
word_cues=[]
for st,en,text in sentence_cues:
    words=re.findall(r"[A-Za-z0-9]+(?:'[A-Za-z]+)?|[—–-]|[^\sA-Za-z0-9]", text)
    words=[w for w in words if re.search(r'[A-Za-z0-9]', w)]
    if not words: continue
    weights=[max(0.55, min(1.8, len(w)/5)) for w in words]
    total=sum(weights); t=st
    for w,wt in zip(words,weights):
        dur=(en-st)*wt/total
        word_cues.append({'start':round(t,3),'end':round(t+dur,3),'word':w,'sentence':text})
        t+=dur
WORD_JSON.write_text(json.dumps(word_cues,ensure_ascii=False,indent=2))

def fmt_srt(t):
    ms=int(round((t-int(t))*1000)); sec=int(t)%60; minutes=(int(t)//60)%60; h=int(t)//3600
    return f"{h:02d}:{minutes:02d}:{sec:02d},{ms:03d}"
WORD_SRT.write_text('\n\n'.join(f"{i}\n{fmt_srt(c['start'])} --> {fmt_srt(c['end'])}\n{c['word']}" for i,c in enumerate(word_cues,1)))

# fonts
font_paths=['/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf','/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf']
font=ImageFont.truetype(font_paths[0], 24)
font_b=ImageFont.truetype(font_paths[1], 34)
font_big=ImageFont.truetype(font_paths[1], 42)
font_small=ImageFont.truetype(font_paths[0], 18)
font_tiny=ImageFont.truetype(font_paths[0], 15)
font_word=ImageFont.truetype(font_paths[1], 44)

scenes=[
 {'id':'s01_fail','start':0,'end':5,'title':'AI SUMMARY WITHOUT A DECISION TARGET','label':'FAIL','desc':'47-minute transcript vs. polished but useless summary'},
 {'id':'s02_bad_output','start':5,'end':12,'title':'BAD OUTPUT LOOKS POLISHED','label':'ANTI-EXAMPLE','desc':'“Great discussion” — but no owner, blocker or decision'},
 {'id':'s03_mechanism','start':12,'end':21,'title':'MECHANISM: EQUAL COMPRESSION','label':'MECHANISM','desc':'AI compresses every sentence equally when no decision target is named'},
 {'id':'s04_fix','start':21,'end':30,'title':'FIX: ASK FOR DECISION EVIDENCE','label':'FIX','desc':'Blockers → Owners → Risks → Next steps'},
 {'id':'s05_takeaway','start':30,'end':38,'title':'TAKEAWAY','label':'RULE','desc':'Don’t ask for a summary. Ask for decision evidence.'},
]

def current_scene(t):
    for s in scenes:
        if s['start'] <= t < s['end']:
            return s
    return scenes[-1]

def current_word(t):
    for i,c in enumerate(word_cues):
        if c['start'] <= t < c['end']:
            return i,c
    return None,None

def wrap_text(draw, text, font, max_width):
    words=text.split(); lines=[]; cur=''
    for w in words:
        test=(cur+' '+w).strip()
        if draw.textbbox((0,0),test,font=font)[2] <= max_width:
            cur=test
        else:
            if cur: lines.append(cur)
            cur=w
    if cur: lines.append(cur)
    return lines

def rounded(draw, box, fill, outline=None, width=2, radius=18):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)

def draw_centered(draw, y, text, font, fill, max_width=W-60, line_gap=6):
    lines=wrap_text(draw,text,font,max_width)
    total=sum(draw.textbbox((0,0),l,font=font)[3]-draw.textbbox((0,0),l,font=font)[1] for l in lines)+line_gap*(len(lines)-1)
    yy=y-total/2
    for l in lines:
        bb=draw.textbbox((0,0),l,font=font); tw=bb[2]-bb[0]; th=bb[3]-bb[1]
        draw.text(((W-tw)/2,yy),l,font=font,fill=fill)
        yy += th+line_gap

def draw_caption(draw,t):
    idx,c=current_word(t)
    if c is None: return
    sent=c['sentence']
    # full sentence as context
    rounded(draw,(28,728,W-28,842),(14,18,28),(72,90,140),2,20)
    lines=wrap_text(draw,sent,font, W-80)
    yy=745
    for line in lines[:3]:
        bb=draw.textbbox((0,0),line,font=font); draw.text(((W-(bb[2]-bb[0]))/2,yy),line,font=font,fill=(225,230,245)); yy+=30
    # active word pill
    active=c['word'].upper()
    bb=draw.textbbox((0,0),active,font=font_word); tw=bb[2]-bb[0]
    rounded(draw,((W-tw)/2-28,850,(W+tw)/2+28,918),(245,196,55),(255,238,130),2,24)
    draw.text(((W-tw)/2,858),active,font=font_word,fill=(20,22,30))

def draw_visual(draw,t,s):
    # base cards by scene
    progress=(t-s['start'])/(s['end']-s['start'])
    pulse=int(10*math.sin(t*4))
    if s['id']=='s01_fail':
        rounded(draw,(28,190,250,560),(34,42,62),(76,104,160),2)
        rounded(draw,(290,190,512,560),(44,45,52),(190,76,76),2)
        draw.text((58,215),'TRANSCRIPT',font=font_small,fill=(160,190,255))
        draw.text((323,215),'AI SUMMARY',font=font_small,fill=(255,190,190))
        for i in range(11):
            y=255+i*24
            draw.line((55,y,215-(i%3)*22,y),fill=(92,112,150),width=4)
        for i in range(5):
            y=270+i*34
            draw.line((322,y,475-(i%2)*50,y),fill=(125,125,135),width=5)
        # stamp
        draw.rotate if False else None
        rounded(draw,(330,440,475,500),(125,28,34),(255,90,90),3,10)
        draw.text((353,454),'USELESS',font=font_b,fill=(255,230,230))
        draw.line((270,230,270,540),fill=(240,210,80),width=4)
    elif s['id']=='s02_bad_output':
        rounded(draw,(58,205,482,545),(32,36,48),(105,120,170),2,26)
        draw.text((92,235),'POLISHED SUMMARY',font=font_small,fill=(190,205,245))
        for i,w in enumerate(['great discussion','strong alignment','next steps discussed']):
            y=295+i*48
            rounded(draw,(90,y,450,y+28),(55,60,76),None,1,8)
            draw.line((105,y+14,340+i*25,y+14),fill=(115,125,150),width=5)
        for label,y in [('NO OWNER',444),('NO BLOCKER',482),('NO DECISION',520)]:
            draw.text((96,y),label,font=font_small,fill=(255,100,100))
            draw.line((210,y+10,430,y+10),fill=(255,85,85),width=3)
    elif s['id']=='s03_mechanism':
        cx=270; box_y=395
        for i in range(9):
            x=50+(i%3)*120; y=205+(i//3)*70
            rounded(draw,(x,y,x+92,y+38),(40,50,75),(85,105,150),2,9)
            draw.line((x+14,y+19,cx,box_y),fill=(90,110,150),width=2)
        rounded(draw,(175,385,365,500),(40,45,55),(245,196,55),3,20)
        draw.text((207,426),'GENERIC',font=font_b,fill=(235,235,235))
        rounded(draw,(190,560,350,612),(50,80,55),(90,220,120),2,14)
        draw.text((207,573),'DECISION?',font=font_small,fill=(180,255,190))
        draw.line((270,560,270,505),fill=(255,80,80),width=4)
    elif s['id']=='s04_fix':
        nodes=[('BLOCKERS',90),('OWNERS',195),('RISKS',300),('NEXT STEPS',405)]
        for i,(label,x) in enumerate(nodes):
            rounded(draw,(x,330,x+85,410),(38,65,65),(98,220,190),2,14)
            draw.text((x+8,365),label,font=font_tiny,fill=(210,255,245))
            if i>0:
                draw.line((nodes[i-1][1]+85,370,x,370),fill=(245,196,55),width=4)
        rounded(draw,(120,505,420,575),(30,55,45),(110,230,130),3,18)
        draw.text((155,526),'DECISION EVIDENCE',font=font_b,fill=(215,255,225))
    else:
        rounded(draw,(40,210,248,520),(54,45,52),(150,90,90),2,18)
        rounded(draw,(292,210,500,520),(35,58,50),(90,210,130),2,18)
        draw.text((92,240),'BEFORE',font=font_b,fill=(255,190,190))
        draw.text((353,240),'AFTER',font=font_b,fill=(180,255,190))
        for i in range(5):
            draw.line((78,300+i*36,205,300+i*36),fill=(125,110,120),width=6)
        for i,label in enumerate(['owner','risk','next step']):
            y=310+i*50
            rounded(draw,(330,y,460,y+28),(55,78,62),None,1,8)
            draw.text((350,y+5),label,font=font_tiny,fill=(200,255,210))
        draw.line((252,365,288,365),fill=(245,196,55),width=5)
        draw.polygon([(288,365),(274,356),(274,374)],fill=(245,196,55))

# video writer
fourcc=cv2.VideoWriter_fourcc(*'mp4v')
writer=cv2.VideoWriter(str(SILENT), fourcc, FPS, (W,H))
for frame in range(int(DURATION*FPS)):
    t=frame/FPS
    img=Image.new('RGB',(W,H),(10,14,24))
    draw=ImageDraw.Draw(img)
    # background gradient stripes
    for y in range(0,H,4):
        shade=18+int(18*y/H)
        draw.line((0,y,W,y),fill=(shade,shade+4,shade+14),width=4)
    s=current_scene(t)
    # header
    rounded(draw,(18,18,522,78),(24,30,44),(70,90,140),2,18)
    draw.text((36,30),'PREVIEW / NOT FINAL',font=font_b,fill=(245,196,55))
    draw.text((36,86),f"{s['id']}  •  {s['label']}",font=font_small,fill=(160,180,220))
    draw_centered(draw,145,s['title'],font_b,(235,240,255),W-50)
    draw_visual(draw,t,s)
    draw_centered(draw,645,s['desc'],font,(205,218,245),W-70)
    draw_caption(draw,t)
    # progress bar
    draw.rounded_rectangle((36,938,504,948),radius=5,fill=(50,55,70))
    draw.rounded_rectangle((36,938,36+int(468*t/DURATION),948),radius=5,fill=(245,196,55))
    arr=np.array(img)[:,:,::-1]
    writer.write(arr)
writer.release()

# mux audio, preserve planned 38s duration
cmd=f"ffmpeg -y -i {SILENT} -i {AUDIO} -map 0:v:0 -map 1:a:0 -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 23 -c:a aac -b:a 160k -t {DURATION} -movflags +faststart {MP4}"
res=terminal(cmd, timeout=180)
# durations and checksum
def ffprobe_duration(path):
    return float(terminal(f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {path}")['output'].strip())
sha=hashlib.sha256(MP4.read_bytes()).hexdigest()
meta={
 'candidate_id':'ai-output-autopsy-decision-summary-38s',
 'version':'v2.0.0','script_hash':'72129542b86819fd',
 'approval_state':'APPROVED_FOR_PREVIEW_RENDER',
 'resource_tier':'DETERMINISTIC_PREVIEW',
 'audio_duration_seconds':ffprobe_duration(AUDIO),
 'preview_duration_seconds':ffprobe_duration(MP4),
 'caption_alignment':'time-based word cues derived from EdgeTTS sentence SRT; not forced alignment',
 'quality_gate':'ready_for_review; captions are time-based, not massively async in planned preview',
 'mp4_path':str(MP4),'audio_path':str(AUDIO),'subtitle_srt_path':str(WORD_SRT),'edge_sentence_srt_path':str(SRT),'word_cues_json_path':str(WORD_JSON),
 'sha256':sha,
 'wan_calls':False,'ai_image_generation':False,'platform_api':False,'auto_posting':False
}
META.write_text(json.dumps(meta,indent=2))
print(res['output'][-1200:])
print(json.dumps(meta,indent=2))


Output: ⏰ Script timed out after 300s and was killed.

Script timed out after 300s and was killed.


terminal

python - <<'PY'
from pathlib import Path
out=Path('/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s')
for p in sorted(out.iterdir()): print(p.name, p.stat().st_size)
PY


Output:
edge_subtitles.srt 775
preview_silent.mp4 8166438
voiceover.mp3 209808
voiceover.txt 477
word_cues_time_based.json 11745
word_cues_time_based.srt 3101


OUT=/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s; ffmpeg -y -i "$OUT/preview_silent.mp4" -i "$OUT/voiceover.mp3" -map 0:v:0 -map 1:a:0 -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 24 -c:a aac -b:a 160k -t 38 -movflags +faststart "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4" && ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4" && sha256sum "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4"


Output:
ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers
  built with gcc 13 (Ubuntu 13.2.0-23ubuntu3)
  configuration: --prefix=/usr --extra-version=3ubuntu5 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --disable-omx --enable-gnutls --enable-libaom --enable-libass --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libglslang --enable-libgme --enable-libgsm --enable-libharfbuzz --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-openal --enable-opencl --enable-opengl --disable-sndio --enable-libvpl --disable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-ladspa --enable-libbluray --enable-libjack --enable-libpulse --enable-librabbitmq --enable-librist --enable-libsrt --enable-libssh --enable-libsvtav1 --enable-libx264 --enable-libzmq --enable-libzvbi --enable-lv2 --enable-sdl2 --enable-libplacebo --enable-librav1e --enable-pocketsphinx --enable-librsvg --enable-libjxl --enable-shared
  libavutil      58. 29.100 / 58. 29.100
  libavcodec     60. 31.102 / 60. 31.102
  libavformat    60. 16.100 / 60. 16.100
  libavdevice    60.  3.100 / 60.  3.100
  libavfilter     9. 12.100 /  9. 12.100
  libswscale      7.  5.100 /  7.  5.100
  libswresample   4. 12.100 /  4. 12.100
  libpostproc    57.  3.100 / 57.  3.100
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_silent.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2mp41
    encoder         : Lavf62.3.100
  Duration: 00:00:38.00, start: 0.000000, bitrate: 1719 kb/s
  Stream #0:0[0x1](und): Video: mpeg4 (Simple Profile) (mp4v / 0x7634706D), yuv420p, 540x960 [SAR 1:1 DAR 9:16], 1718 kb/s, 30 fps, 30 tbr, 15360 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
[mp3 @ 0x5afafa7a5c40] Estimating duration from bitrate, this may be inaccurate
Input #1, mp3, from '/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3':
  Duration: 00:00:34.97, start: 0.000000, bitrate: 48 kb/s
  Stream #1:0: Audio: mp3, 24000 Hz, mono, fltp, 48 kb/s
Stream mapping:
  Stream #0:0 -> #0:0 (mpeg4 (native) -> h264 (libx264))
  Stream #1:0 -> #0:1 (mp3 (mp3float) -> aac (native))
Press [q] to stop, [?] for help
[libx264 @ 0x5afafa7cec80] using SAR=1/1
[libx264 @ 0x5afafa7cec80] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512
[libx264 @ 0x5afafa7cec80] profile High, level 3.1, 4:2:0, 8-bit
[libx264 @ 0x5afafa7cec80] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=9 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=10 rc=crf mbtree=1 crf=24.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
[aac @ 0x5afafa7fda00] Too many bits 6826.666667 > 6144 per frame requested, clamping to max
Output #0, mp4, to '/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2mp41
    encoder         : Lavf60.16.100
  Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 540x960 [SAR 1:1 DAR 9:16], q=2-31, 30 fps, 15360 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
  Stream #0:1: Audio: aac (LC) (mp4a / 0x6134706D), 24000 Hz, mono, fltp, 144 kb/s
    Metadata:
      encoder         : Lavc60.31.102 aac
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=  193 fps=0.0 q=30.0 size=       0kB time=00:00:07.04 bitrate=   0.1kbits/s speed=13.8x
frame=  420 fps=416 q=30.0 size=     256kB time=00:00:14.59 bitrate= 143.7kbits/s speed=14.5x
frame=  657 fps=435 q=30.0 size=     256kB time=00:00:22.52 bitrate=  93.1kbits/s speed=14.9x
frame=  914 fps=455 q=30.0 size=     512kB time=00:00:31.06 bitrate= 135.0kbits/s speed=15.4x
[mp4 @ 0x5afafa7cd740] Starting second pass: moving the moov atom to the beginning of the file
[out#0/mp4 @ 0x5afafa7a6800] video:340kB audio:388kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 4.088195%
frame= 1140 fps=475 q=-1.0 Lsize=     757kB time=00:00:37.90 bitrate= 163.7kbits/s speed=15.8x
[libx264 @ 0x5afafa7cec80] frame I:5     Avg QP:16.06  size: 24621
[libx264 @ 0x5afafa7cec80] frame P:289   Avg QP:14.64  size:   664
[libx264 @ 0x5afafa7cec80] frame B:846   Avg QP:15.22  size:    38
[libx264 @ 0x5afafa7cec80] consecutive B-frames:  0.7%  0.5%  1.6% 97.2%
[libx264 @ 0x5afafa7cec80] mb I  I16..4: 73.9%  2.3% 23.8%
[libx264 @ 0x5afafa7cec80] mb P  I16..4:  1.2%  0.0%  0.3%  P16..4:  0.7%  0.2%  0.2%  0.0%  0.0%    skip:97.4%
[libx264 @ 0x5afafa7cec80] mb B  I16..4:  0.0%  0.0%  0.0%  B16..8:  0.4%  0.0%  0.0%  direct: 0.0%  skip:99.5%  L0:50.0% L1:49.7% BI: 0.4%
[libx264 @ 0x5afafa7cec80] 8x8 transform intra:2.1% inter:7.4%
[libx264 @ 0x5afafa7cec80] coded y,uvDC,uvAC intra: 15.6% 19.2% 14.0% inter: 0.1% 0.1% 0.1%
[libx264 @ 0x5afafa7cec80] i16 v,h,dc,p: 58% 40%  2%  0%
[libx264 @ 0x5afafa7cec80] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 20% 22% 52%  2%  1%  1%  1%  0%  1%
[libx264 @ 0x5afafa7cec80] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 35% 22% 19%  3%  4%  6%  4%  4%  3%
[libx264 @ 0x5afafa7cec80] i8c dc,h,v,p: 52% 32% 15%  1%
[libx264 @ 0x5afafa7cec80] Weighted P-Frames: Y:0.0% UV:0.0%
[libx264 @ 0x5afafa7cec80] kb/s:73.09
[aac @ 0x5afafa7fda00] Qavg: 65484.609
38.000000
d14ca8703ff7be72538e83ced8fc8bd1ed698593d57e11e8b5554d678209b41f  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4


Explanation:
**Computing metadata for audio**

I'm thinking about how I need to write metadata for audio files. It seems like I should compute the audio duration and possibly gather other relevant information too. The terminal might be the best way to do this since it allows for precise commands. I'm not sure what the exact commands are yet, but I’ll look into that! I want to make sure I'm capturing everything accurately for the user.

from pathlib import Path
import json, hashlib, subprocess
from hermes_tools import terminal
out=Path('/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s')
def dur(p):
 return float(terminal(f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {p}")['output'].strip())
mp4=out/'preview_ai-output-autopsy-decision-summary-38s_v2.mp4'
meta={
 'candidate_id':'ai-output-autopsy-decision-summary-38s','version':'v2.0.0','script_hash':'72129542b86819fd',
 'approval_state':'APPROVED_FOR_PREVIEW_RENDER','resource_tier':'DETERMINISTIC_PREVIEW',
 'mp4_path':str(mp4),'audio_path':str(out/'voiceover.mp3'),'subtitle_srt_path':str(out/'word_cues_time_based.srt'),'edge_sentence_srt_path':str(out/'edge_subtitles.srt'),'word_cues_json_path':str(out/'word_cues_time_based.json'),
 'audio_duration_seconds':dur(out/'voiceover.mp3'),'preview_duration_seconds':dur(mp4),'sha256':hashlib.sha256(mp4.read_bytes()).hexdigest(),
 'caption_alignment':'time-based word cues derived from EdgeTTS sentence SRT; forced alignment not used in this preview',
 'quality_gate':'ready_for_review; captions are time-based and visually usable for preview, but require forced-alignment upgrade before final',
 'wan_calls':False,'ai_image_generation':False,'platform_api':False,'auto_posting':False}
(out/'preview_metadata.json').write_text(json.dumps(meta,indent=2))
print(json.dumps(meta,indent=2))


Output:
{
  "candidate_id": "ai-output-autopsy-decision-summary-38s",
  "version": "v2.0.0",
  "script_hash": "72129542b86819fd",
  "approval_state": "APPROVED_FOR_PREVIEW_RENDER",
  "resource_tier": "DETERMINISTIC_PREVIEW",
  "mp4_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4",
  "audio_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3",
  "subtitle_srt_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.srt",
  "edge_sentence_srt_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/edge_subtitles.srt",
  "word_cues_json_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.json",
  "audio_duration_seconds": 34.968,
  "preview_duration_seconds": 38.0,
  "sha256": "d14ca8703ff7be72538e83ced8fc8bd1ed698593d57e11e8b5554d678209b41f",
  "caption_alignment": "time-based word cues derived from EdgeTTS sentence SRT; forced alignment not used in this preview",
  "quality_gate": "ready_for_review; captions are time-based and visually usable for preview, but require forced-alignment upgrade before final",
  "wan_calls": false,
  "ai_image_generation": false,
  "platform_api": false,
  "auto_posting": false
}


Explanation:
**Considering visual verification**

I'm thinking I need to verify something visually. I might use a vision analysis tool or the terminal with ffprobe. It seems like taking a screenshot could be useful too, possibly using ffmpeg to extract a frame. So, I’ll have to run a few commands in the terminal to get that screenshot, then run the vision analysis on it. This way, I can check exactly what I need!

OUT=/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s; ffmpeg -y -ss 14 -i "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4" -frames:v 1 "$OUT/preview_midframe.jpg" >/dev/null 2>&1 && file "$OUT/preview_midframe.jpg" && ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate -of default=noprint_wrappers=1 "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4"


Output:
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_midframe.jpg: JPEG image data, JFIF standard 1.02, aspect ratio, density 1x1, segment length 16, comment: "Lavc60.31.102", baseline, precision 8, 540x960, components 3
width=540
height=960
r_frame_rate=30/1


Question: Check whether this preview frame clearly shows PREVIEW/NOT FINAL, a readable 9:16 concept visual, and readable captions/overlays. Note any major issue.


Image loaded into your context — you can see it natively now. Use your built-in vision to answer the user.

Question: Check whether this preview frame clearly shows PREVIEW/NOT FINAL, a readable 9:16 concept visual, and readable captions/overlays. Note any major issue.


python -m pytest tests -q && git status --short


Output:
........................................................................ [ 36%]
........................................................................ [ 72%]
......................................................                   [100%]
198 passed in 32.80s
 M autoshorts/production/resource_gate.py
?? tests/test_preview_render_gate_v2.py


Explanation:
**Evaluating Git and Telegram details**

I think I need to check the Git status and confirm the commit hash from sprint 1.6, which is b1766a4. I'm considering if the Telegram delivery was successful; there seems to be a German phrase mentioning success. If media is included after the final delivery, I might say "ich hänge es dieser Antwort an," but I'm not entirely sure. I also want to make sure to include the MEDIA line and list the file sizes, especially if Telegram can handle local media paths.

OUT=/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s; du -h "$OUT"/* | sort -h; echo HASH; sha256sum "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2.mp4" "$OUT/voiceover.mp3" "$OUT/word_cues_time_based.srt"; echo GIT; git log --oneline -1; git status --short


Output:
4,0K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/edge_subtitles.srt
4,0K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_metadata.json
4,0K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.txt
4,0K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.srt
12K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.json
44K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_midframe.jpg
208K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3
760K	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4
7,8M	/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_silent.mp4
HASH
d14ca8703ff7be72538e83ced8fc8bd1ed698593d57e11e8b5554d678209b41f  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4
a990930cb75dc9cc5a6b2d141cd5c295cce6e713ced744998f0eda15d4c1ac4f  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3
6aa8c6c1048c09abbd9441e5d0ac32320e50acdf96197572aabc266122d6c335  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.srt
GIT
b1766a4 Add pilot production package and resource gates
 M autoshorts/production/resource_gate.py
?? tests/test_preview_render_gate_v2.py


Sir, Sprint 1.7 ist ausgeführt. Das erste echte **Preview-/Konzeptvideo** wurde erstellt — mit TTS, Timing, Captions, Szenenfolge, Overlays und deterministischen Placeholder-Visuals.

Kein Wan-2.2-Render. Keine AI-Bildgenerierung. Keine Plattform-API. Kein Auto-Posting. Der zivilisierte Weg, bevor wir die GPU-Kanone laden.

MEDIA:/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4

## Git Status

Aktueller Branch:

`strategy-refactor-v2`

Sprint-1.6 wurde committed:

```text
b1766a4 Add pilot production package and resource gates
```

Aktueller Status nach Sprint 1.7:

```text
 M autoshorts/production/resource_gate.py
?? tests/test_preview_render_gate_v2.py
```

Sprint 1.7 selbst ist noch **nicht committed**.

## Pytest Ergebnis

```text
198 passed in 32.80s
```

## Preview Approval State

```text
APPROVED_FOR_PREVIEW_RENDER
```

Freigabe genutzt für:

- TTS-Erzeugung: erlaubt
- Subtitle-/Caption-Timing: erlaubt
- deterministische Preview / Animatic: erlaubt
- Telegram Preview Delivery: über diese Antwort als Media Attachment
- Wan-Finalrender: weiterhin blockiert
- AI-Styleframe: weiterhin blockiert
- Publishing: weiterhin blockiert

## Erzeugte Dateien

Basisordner:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/
```

Wichtige Dateien:

```text
preview_ai-output-autopsy-decision-summary-38s_v2.mp4
voiceover.mp3
voiceover.txt
edge_subtitles.srt
word_cues_time_based.srt
word_cues_time_based.json
preview_metadata.json
preview_midframe.jpg
preview_silent.mp4
```

## MP4

Pfad:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4
```

SHA256:

```text
d14ca8703ff7be72538e83ced8fc8bd1ed698593d57e11e8b5554d678209b41f
```

Format:

- 9:16
- 540 × 960
- 30 fps
- Preview / Not Final sichtbar eingeblendet
- renderer-owned Captions, Labels, Pfeile, USELESS-Stempel und Takeaway

## Audio

Pfad:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3
```

SHA256:

```text
a990930cb75dc9cc5a6b2d141cd5c295cce6e713ced744998f0eda15d4c1ac4f
```

TTS:

- Voice: `en-GB-RyanNeural`
- Rate: `-3%`
- Stil: ruhig, klar, professionell, nicht hype

Audiodauer:

```text
34.968s
```

## Preview-Dauer

```text
38.000s
```

Die letzten Sekunden geben dem Takeaway etwas Luft, statt das Video panisch abzuschneiden — ein seltener Triumph über Shorts-Hektik.

## Subtitle / Caption Dateien

Sentence-SRT von EdgeTTS:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/edge_subtitles.srt
```

Time-based Word-Cues:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.srt
```

Word-Cue JSON:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.json
```

Caption-Status:

- bestmögliche zeitbasierte Word-by-word Preview
- aus EdgeTTS-Sentence-Timings abgeleitet
- **kein echtes Forced Alignment**
- für Review brauchbar
- für Finalrender sollte Forced Alignment nachgerüstet werden

Quality Gate:

```text
ready_for_review
```

Einschränkung:

```text
Captions sind zeitbasiert, nicht forced-aligned. Nicht massiv asynchron, aber noch nicht final-grade.
```

## Szenenfolge

### s01_fail — ca. 5s

Split-screen Transcript vs. useless AI summary
Overlay: PREVIEW / NOT FINAL, Hook, USELESS-Stempel

### s02_bad_output — ca. 7s

Polished bad summary without decision evidence
Overlay: NO OWNER / NO BLOCKER / NO DECISION

### s03_mechanism — ca. 9s

Notes compressed equally into generic summary
Overlay: mechanism diagram, decision target

### s04_fix — ca. 9s

Route from messy notes to blockers / owners / risks / next steps
Overlay: structured decision-evidence route

### s05_takeaway — ca. 8s

Before/After contrast and final takeaway
Overlay: final rule

## Bekannte Einschränkungen

- Visuals sind bewusst deterministisch und grob, kein finaler Motion-Renderer.
- Keine Wan-2.2-Generierung.
- Keine AI-Backgrounds.
- Captions sind zeitbasiert, nicht forced-aligned.
- Voiceover ist Preview-TTS, noch kein finaler Audio-Master.
- Motion ist funktional/animatic, nicht final-polished.
- Finales Visual Design muss nach Review noch verfeinert werden.

## Telegram Versand

Ich hänge das Preview-MP4 dieser Antwort direkt als Telegram-Media an.

Falls Telegram das Attachment wider Erwarten nicht sauber annimmt, liegt die Datei lokal hier:

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2.mp4
```

## Nicht ausgeführt

Explizit **nicht** ausgeführt:

- kein Wan-2.2-Finalrender
- keine Wan-Calls
- keine AI-Bildgenerierung
- keine Plattform-API
- kein Auto-Posting
- kein finales Manual Posting Pack

## Nächste Review Commands

Wenn Sie überarbeiten wollen:

```text
REVISE ai-output-autopsy-decision-summary-38s <field> "Änderungswunsch"
```

Wenn das Preview passt und genau ein finaler Wan-2.2-Render erlaubt ist:

```text
APPROVED_FOR_FINAL_RENDER ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd
```

Wenn Sie ablehnen:

```text
REJECT ai-output-autopsy-decision-summary-38s "Grund"
```


[M G] Man sollte jeweils immer mindestens 3 wörter der subtitles sehen, wobei die gerade gesprochenen wörter aufblinken (harmozi style). Aktuell kommt jedes einzeln, das ist zu nervös. Korrigiere das.


Explanation:
**Considering media generation**

I might need to send some media. I'll use execute_code with a more efficient script at about 15 FPS since the previous one timed out due to frame generation. It was slow, taking 38 seconds for 30 frames. I want to generate at 24 FPS instead, which should be 912 frames. I’ll write a Python script and execute it in the terminal with a timeout of 600 seconds, while also focusing on adapting existing visuals. Let's write the file to the temporary directory!
**Managing memory and preferences**

It looks like my memory is full, so I might need to replace the AutoShorts preference to include a minimum 3-word caption window. I should consider whether to replace my current profile, which has AutoShorts and includes synced captions. I’ll handle this later. I don’t need to use arithmetic since I'm already utilizing the tool. I also want to inspect the image afterwards, and then I'll finalize everything in a concise German summary.

memory

AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; sync captions tightly.

AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; captions show ≥3-word window with active word highlight, tightly synced.


**Voice preference:** de-DE-KillianNeural (German male voice, natural/clear) — default for all TTS output

**Language:** Deutsch mit englischen Fachbegriffen

Kommunikationspräferenzen: **Sprachnachricht → Antwort per Sprachnachricht**

Kommunikationspräferenzen: **Textnachricht → Antwort per Text**

Health advisor role: consolidate labs/clinical reports/YAZIO/daily health email/symptoms/events into dashboards/reports; lab extraction requires 4-eyes review before values are confirmed.

Telegram topic Health-Med: only medical strategy/Behçet/Major Vessel Disease/thrombosis/meds/nutrition/symptoms/labs/doctor questions; health system scripts/data models go to Health-Tech.

Finance/Grocery: no real amounts/IDs/API keys in chat; credentials via runtime files; no DB/CSV/keys/reports in Git/frontend; backups+audit; Preview→Confirm→Audit; no fake prices/buttons; OpenFIGI never show/store; never auto-book; grocery provider calls explicit/cache-first.

AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; captions show ≥3-word window with active word highlight, tightly synced.

Entry replaced.


from pathlib import Path
import json, re, math, hashlib
from PIL import Image, ImageDraw, ImageFont
import cv2
import numpy as np
import subprocess

OUT=Path('/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s')
AUDIO=OUT/'voiceover.mp3'
WORD_JSON=OUT/'word_cues_time_based.json'
SILENT=OUT/'preview_silent_caption_window_v2.mp4'
MP4=OUT/'preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4'
META=OUT/'preview_metadata_caption_window.json'
W,H,FPS=540,960,24
DURATION=38.0
word_cues=json.loads(WORD_JSON.read_text())

font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 24)
font_b=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 34)
font_big=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 42)
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
font_tiny=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 15)
font_word=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 35)

scenes=[
 {'id':'s01_fail','start':0,'end':5,'title':'AI SUMMARY WITHOUT A DECISION TARGET','label':'FAIL','desc':'47-minute transcript vs. polished but useless summary'},
 {'id':'s02_bad_output','start':5,'end':12,'title':'BAD OUTPUT LOOKS POLISHED','label':'ANTI-EXAMPLE','desc':'“Great discussion” — but no owner, blocker or decision'},
 {'id':'s03_mechanism','start':12,'end':21,'title':'MECHANISM: EQUAL COMPRESSION','label':'MECHANISM','desc':'AI compresses every sentence equally when no decision target is named'},
 {'id':'s04_fix','start':21,'end':30,'title':'FIX: ASK FOR DECISION EVIDENCE','label':'FIX','desc':'Blockers → Owners → Risks → Next steps'},
 {'id':'s05_takeaway','start':30,'end':38,'title':'TAKEAWAY','label':'RULE','desc':'Don’t ask for a summary. Ask for decision evidence.'},
]

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

def current_scene(t):
    for s in scenes:
        if s['start'] <= t < s['end']:
            return s
    return scenes[-1]

def current_word_index(t):
    for i,c in enumerate(word_cues):
        if c['start'] <= t < c['end']:
            return i
    # nearest previous word
    prev=[i for i,c in enumerate(word_cues) if c['start'] <= t]
    return prev[-1] if prev else 0

def wrap_text(draw, text, font, max_width):
    words=text.split(); lines=[]; cur=''
    for w in words:
        test=(cur+' '+w).strip()
        if draw.textbbox((0,0),test,font=font)[2] <= max_width:
            cur=test
        else:
            if cur: lines.append(cur)
            cur=w
    if cur: lines.append(cur)
    return lines

def rounded(draw, box, fill, outline=None, width=2, radius=18):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)

def draw_centered(draw, y, text, font, fill, max_width=W-60, line_gap=6):
    lines=wrap_text(draw,text,font,max_width)
    heights=[draw.textbbox((0,0),l,font=font)[3]-draw.textbbox((0,0),l,font=font)[1] for l in lines]
    total=sum(heights)+line_gap*(len(lines)-1)
    yy=y-total/2
    for l,th in zip(lines,heights):
        bb=draw.textbbox((0,0),l,font=font); tw=bb[2]-bb[0]
        draw.text(((W-tw)/2,yy),l,font=font,fill=fill)
        yy += th+line_gap

def draw_caption_window(draw,t):
    idx=current_word_index(t)
    # Always show at least 3 words, generally 5 for calmer reading.
    start=max(0, idx-2)
    end=min(len(word_cues), start+5)
    if end-start < 3:
        start=max(0,end-3)
    window=word_cues[start:end]
    rounded(draw,(24,742,W-24,914),(13,17,28),(72,90,140),2,24)
    draw.text((42,755),'WORD-BY-WORD PREVIEW  •  3+ WORD WINDOW',font=font_tiny,fill=(145,165,210))
    # Lay words centered with active word pill; wrap to two rows if necessary.
    tokens=[]
    for j,c in enumerate(window,start):
        word=c['word'].upper() if j==idx else c['word']
        f=font_word if j==idx else font_b
        bb=draw.textbbox((0,0),word,font=f)
        tokens.append({'idx':j,'word':word,'font':f,'w':bb[2]-bb[0],'h':bb[3]-bb[1]})
    rows=[[]]
    curw=0
    for tok in tokens:
        add=tok['w']+18
        if curw+add > W-80 and rows[-1]:
            rows.append([]); curw=0
        rows[-1].append(tok); curw += add
    y=807 if len(rows)==1 else 790
    for row in rows[:2]:
        total=sum(tok['w'] for tok in row)+18*(len(row)-1)
        x=(W-total)/2
        maxh=max(tok['h'] for tok in row)
        for tok in row:
            active=tok['idx']==idx
            if active:
                rounded(draw,(x-12,y-9,x+tok['w']+12,y+maxh+15),(245,196,55),(255,238,130),2,17)
                fill=(16,18,26)
            else:
                fill=(220,228,245)
            draw.text((x,y),tok['word'],font=tok['font'],fill=fill)
            x += tok['w']+18
        y += maxh+28

def draw_visual(draw,t,s):
    if s['id']=='s01_fail':
        rounded(draw,(28,190,250,560),(34,42,62),(76,104,160),2)
        rounded(draw,(290,190,512,560),(44,45,52),(190,76,76),2)
        draw.text((58,215),'TRANSCRIPT',font=font_small,fill=(160,190,255))
        draw.text((323,215),'AI SUMMARY',font=font_small,fill=(255,190,190))
        for i in range(11):
            y=255+i*24; draw.line((55,y,215-(i%3)*22,y),fill=(92,112,150),width=4)
        for i in range(5):
            y=270+i*34; draw.line((322,y,475-(i%2)*50,y),fill=(125,125,135),width=5)
        rounded(draw,(330,440,475,500),(125,28,34),(255,90,90),3,10)
        draw.text((353,454),'USELESS',font=font_b,fill=(255,230,230))
        draw.line((270,230,270,540),fill=(240,210,80),width=4)
    elif s['id']=='s02_bad_output':
        rounded(draw,(58,205,482,545),(32,36,48),(105,120,170),2,26)
        draw.text((92,235),'POLISHED SUMMARY',font=font_small,fill=(190,205,245))
        for i in range(3):
            y=295+i*48; rounded(draw,(90,y,450,y+28),(55,60,76),None,1,8); draw.line((105,y+14,340+i*25,y+14),fill=(115,125,150),width=5)
        for label,y in [('NO OWNER',444),('NO BLOCKER',482),('NO DECISION',520)]:
            draw.text((96,y),label,font=font_small,fill=(255,100,100)); draw.line((210,y+10,430,y+10),fill=(255,85,85),width=3)
    elif s['id']=='s03_mechanism':
        cx=270; box_y=395
        for i in range(9):
            x=50+(i%3)*120; y=205+(i//3)*70
            rounded(draw,(x,y,x+92,y+38),(40,50,75),(85,105,150),2,9)
            draw.line((x+14,y+19,cx,box_y),fill=(90,110,150),width=2)
        rounded(draw,(175,385,365,500),(40,45,55),(245,196,55),3,20)
        draw.text((207,426),'GENERIC',font=font_b,fill=(235,235,235))
        rounded(draw,(190,560,350,612),(50,80,55),(90,220,120),2,14)
        draw.text((207,573),'DECISION?',font=font_small,fill=(180,255,190))
        draw.line((270,560,270,505),fill=(255,80,80),width=4)
    elif s['id']=='s04_fix':
        nodes=[('BLOCKERS',90),('OWNERS',195),('RISKS',300),('NEXT STEPS',405)]
        for i,(label,x) in enumerate(nodes):
            rounded(draw,(x,330,x+85,410),(38,65,65),(98,220,190),2,14)
            draw.text((x+8,365),label,font=font_tiny,fill=(210,255,245))
            if i>0: draw.line((nodes[i-1][1]+85,370,x,370),fill=(245,196,55),width=4)
        rounded(draw,(120,505,420,575),(30,55,45),(110,230,130),3,18)
        draw.text((155,526),'DECISION EVIDENCE',font=font_b,fill=(215,255,225))
    else:
        rounded(draw,(40,210,248,520),(54,45,52),(150,90,90),2,18)
        rounded(draw,(292,210,500,520),(35,58,50),(90,210,130),2,18)
        draw.text((92,240),'BEFORE',font=font_b,fill=(255,190,190))
        draw.text((353,240),'AFTER',font=font_b,fill=(180,255,190))
        for i in range(5): draw.line((78,300+i*36,205,300+i*36),fill=(125,110,120),width=6)
        for i,label in enumerate(['owner','risk','next step']):
            y=310+i*50; rounded(draw,(330,y,460,y+28),(55,78,62),None,1,8); draw.text((350,y+5),label,font=font_tiny,fill=(200,255,210))
        draw.line((252,365,288,365),fill=(245,196,55),width=5); draw.polygon([(288,365),(274,356),(274,374)],fill=(245,196,55))

fourcc=cv2.VideoWriter_fourcc(*'mp4v')
writer=cv2.VideoWriter(str(SILENT), fourcc, FPS, (W,H))
for frame in range(int(DURATION*FPS)):
    t=frame/FPS
    img=Image.new('RGB',(W,H),(10,14,24)); draw=ImageDraw.Draw(img)
    for y in range(0,H,8):
        shade=18+int(18*y/H); draw.rectangle((0,y,W,y+8),fill=(shade,shade+4,shade+14))
    s=current_scene(t)
    rounded(draw,(18,18,522,78),(24,30,44),(70,90,140),2,18)
    draw.text((36,30),'PREVIEW / NOT FINAL',font=font_b,fill=(245,196,55))
    draw.text((36,86),f"{s['id']}  •  {s['label']}",font=font_small,fill=(160,180,220))
    draw_centered(draw,145,s['title'],font_b,(235,240,255),W-50)
    draw_visual(draw,t,s)
    draw_centered(draw,645,s['desc'],font,(205,218,245),W-70)
    draw_caption_window(draw,t)
    draw.rounded_rectangle((36,938,504,948),radius=5,fill=(50,55,70))
    draw.rounded_rectangle((36,938,36+int(468*t/DURATION),948),radius=5,fill=(245,196,55))
    writer.write(np.array(img)[:,:,::-1])
writer.release()
subprocess.check_call(['ffmpeg','-y','-i',str(SILENT),'-i',str(AUDIO),'-map','0:v:0','-map','1:a:0','-c:v','libx264','-pix_fmt','yuv420p','-preset','veryfast','-crf','24','-c:a','aac','-b:a','160k','-t',str(DURATION),'-movflags','+faststart',str(MP4)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
meta={
 'candidate_id':'ai-output-autopsy-decision-summary-38s','version':'v2.0.0','script_hash':'72129542b86819fd','revision':'caption_window_min_3_words','mp4_path':str(MP4),'audio_path':str(AUDIO),'word_cues_json_path':str(WORD_JSON),'audio_duration_seconds':ffprobe_duration(AUDIO),'preview_duration_seconds':ffprobe_duration(MP4),'sha256':hashlib.sha256(MP4.read_bytes()).hexdigest(),'caption_alignment':'time-based 3-5 word window; active spoken word highlighted Hormozi-style; forced alignment not used'}
META.write_text(json.dumps(meta,indent=2))
print(json.dumps(meta,indent=2))


terminal

Output:
{
  "candidate_id": "ai-output-autopsy-decision-summary-38s",
  "version": "v2.0.0",
  "script_hash": "72129542b86819fd",
  "revision": "caption_window_min_3_words",
  "mp4_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4",
  "audio_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3",
  "word_cues_json_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/word_cues_time_based.json",
  "audio_duration_seconds": 34.968,
  "preview_duration_seconds": 38.0,
  "sha256": "0ed888679bb8379b065132d4f4b6a6a24e8dd18c8c7ee207f2c91112932ce7e0",
  "caption_alignment": "time-based 3-5 word window; active spoken word highlighted Hormozi-style; forced alignment not used"
}


OUT=/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s; ffmpeg -y -ss 17 -i "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4" -frames:v 1 "$OUT/preview_caption_window_midframe.jpg" >/dev/null 2>&1; file "$OUT/preview_caption_window_midframe.jpg"; sha256sum "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4"; ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate -show_entries format=duration -of default=noprint_wrappers=1 "$OUT/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4"


Output:
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_caption_window_midframe.jpg: JPEG image data, JFIF standard 1.02, aspect ratio, density 1x1, segment length 16, comment: "Lavc60.31.102", baseline, precision 8, 540x960, components 3
0ed888679bb8379b065132d4f4b6a6a24e8dd18c8c7ee207f2c91112932ce7e0  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4
width=540
height=960
r_frame_rate=24/1
duration=38.000000


Question: Verify the caption style in this preview frame: does it show at least 3 visible subtitle words at once, with the active word highlighted, and is it readable? Mention any issue.


Image loaded into your context — you can see it natively now. Use your built-in vision to answer the user.

Question: Verify the caption style in this preview frame: does it show at least 3 visible subtitle words at once, with the active word highlighted, and is it readable? Mention any issue.


Explanation:
**Clarifying testing process**

I’m noticing there are at least four active highlighted words regarding the AI compressing things. I’m wondering if I need to run pytest, especially since I've modified the code. I should double-check the code status; perhaps a commit isn't needed since the user requested a correction. Running pytest seems wise to confirm everything is working correctly. It might be great to mention that there are no changes since the last preview, but we still have tests present.

python -m pytest tests/test_preview_render_gate_v2.py tests/test_pilot_production_v2.py -q && git status --short


Output:
........                                                                 [100%]
8 passed in 0.08s
 M autoshorts/production/resource_gate.py
?? tests/test_preview_render_gate_v2.py


Korrigiert, Sir. Sie hatten völlig recht: einzelnes Wort allein ist zu hektisch — wirkt wie ein nervöser Teleprompter auf Espresso.

Ich habe das Preview neu gerendert mit:

- **mindestens 3 sichtbaren Subtitle-Wörtern gleichzeitig**
- meist **3–5 Wörter im Caption-Fenster**
- das gerade gesprochene Wort leuchtet gelb auf
- ruhigerer Hormozi-/TikTok-Style
- Voiceover und Szenen unverändert
- kein Wan-Render
- keine AI-Bildgenerierung
- keine Plattform-API

MEDIA:/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4

## Neue Preview-Datei

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v2_caption_window.mp4
```

SHA256:

```text
0ed888679bb8379b065132d4f4b6a6a24e8dd18c8c7ee207f2c91112932ce7e0
```

## Technische Eckdaten

- Format: 9:16
- Auflösung: 540 × 960
- FPS: 24
- Dauer: 38.000s
- Audio: bestehendes TTS, 34.968s
- Caption Style: **3–5 word window + active word highlight**
- Alignment: weiterhin zeitbasiert aus EdgeTTS-Sentence-Cues, noch kein echtes Forced Alignment

## Verifikation

Ich habe einen Midframe geprüft: Caption-Fenster zeigt mehrere Wörter gleichzeitig, z. B. sinngemäss:

```text
target AI COMPRESSES every
```

Dabei ist **COMPRESSES** aktiv gelb hervorgehoben. Lesbarkeit ist deutlich ruhiger als vorher.

## Tests

```text
8 passed in 0.08s
```

Betroffene Sprint-1.7-Code-Dateien weiterhin uncommitted:

```text
 M autoshorts/production/resource_gate.py
?? tests/test_preview_render_gate_v2.py
```

## Dauerhafte Regel aktualisiert

Ich habe die Caption-Präferenz gespeichert:

> AutoShorts-Captions sollen mindestens ein 3-Wort-Fenster zeigen; das aktuell gesprochene Wort leuchtet aktiv auf.

Nächstes Review-Kommando bleibt:

```text
REVISE ai-output-autopsy-decision-summary-38s <field> "Änderungswunsch"
```

Oder, falls dieses Preview grundsätzlich passt:

```text
APPROVED_FOR_FINAL_RENDER ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd
```


[M G] noch nicht gut. die Wörter des Subtitles abschnitts sollen statisch bleiben, nur das gerade gesprochene wort leuchtet beim abschnitt auf. aktuelle wechseln immer alle wörter gleichzeitig. die Wörter bleiben also pro Satzabschnitt an ort und stelle, nur da "highlight", also das gerade gesprochene wort blinkt jeweils auf. also das highlight wandert von links nach rechts, bis weider der nächste satzabschnitt kommt.


Explanation:
**Planning project details**

I need to act and possibly regenerate a preview with a static segment. I might also update memory using available tools. There’s a thought about whether skills are needed here, but let's proceed. I need to produce media and modify the script, but the user is asking for the correct preview. I’ll render new content, using sentence cues split into 5-8 word chunks. Each chunk will be fixed until all words are spoken, ensuring highlights are active as we go. Let's write a simpler script!

memory

AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; captions show ≥3-word window with active word highlight, tightly synced.

AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; captions use static phrase segments (≥3 words) where only the active spoken word highlights left-to-right.


AutoShorts/TrueTraceShorts: manual approval/posting; previews DE, finals EN. Positioning: hidden digital mechanisms, not generic AI tips; AI only ~30–40%. Format: visible fail→mechanism→fix→takeaway; captions use static phrase segments (≥3 words) where only the active spoken word highlights left-to-right.

Entry replaced.


from pathlib import Path
import json, math, hashlib, subprocess
from PIL import Image, ImageDraw, ImageFont
import cv2
import numpy as np

OUT=Path('/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s')
AUDIO=OUT/'voiceover.mp3'
WORD_JSON=OUT/'word_cues_time_based.json'
SILENT=OUT/'preview_silent_static_caption_segments_v3.mp4'
MP4=OUT/'preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4'
META=OUT/'preview_metadata_static_caption_segments_v3.json'
SEG_JSON=OUT/'caption_static_segments_v3.json'
W,H,FPS=540,960,24
DURATION=38.0
word_cues=json.loads(WORD_JSON.read_text())

font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 24)
font_b=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 34)
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
font_tiny=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 15)
font_word=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 33)
font_word_active=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 35)

scenes=[
 {'id':'s01_fail','start':0,'end':5,'title':'AI SUMMARY WITHOUT A DECISION TARGET','label':'FAIL','desc':'47-minute transcript vs. polished but useless summary'},
 {'id':'s02_bad_output','start':5,'end':12,'title':'BAD OUTPUT LOOKS POLISHED','label':'ANTI-EXAMPLE','desc':'“Great discussion” — but no owner, blocker or decision'},
 {'id':'s03_mechanism','start':12,'end':21,'title':'MECHANISM: EQUAL COMPRESSION','label':'MECHANISM','desc':'AI compresses every sentence equally when no decision target is named'},
 {'id':'s04_fix','start':21,'end':30,'title':'FIX: ASK FOR DECISION EVIDENCE','label':'FIX','desc':'Blockers → Owners → Risks → Next steps'},
 {'id':'s05_takeaway','start':30,'end':38,'title':'TAKEAWAY','label':'RULE','desc':'Don’t ask for a summary. Ask for decision evidence.'},
]

# Build static phrase segments. Words in one segment stay fixed until segment ends;
# only the currently spoken word changes highlight position left-to-right.
segments=[]
by_sentence=[]
last_sentence=None
cur=[]
for i,c in enumerate(word_cues):
    if c['sentence'] != last_sentence:
        if cur: by_sentence.append(cur)
        cur=[]; last_sentence=c['sentence']
    cur.append((i,c))
if cur: by_sentence.append(cur)
for sent_words in by_sentence:
    # chunks of 5-7 words, last chunk merges if it would be <3 words
    chunks=[]; n=len(sent_words); i=0
    while i<n:
        remaining=n-i
        size=6 if remaining>=6 else remaining
        if remaining < 3 and chunks:
            chunks[-1].extend(sent_words[i:]); break
        chunks.append(sent_words[i:i+size]); i+=size
    for chunk in chunks:
        if len(chunk)<3 and len(sent_words)>=3:
            # safety: include preceding words to reach 3
            first_idx=chunk[0][0]
            prev=[x for x in sent_words if x[0]<first_idx][-2:]
            chunk=prev+chunk
        segments.append({
            'start':chunk[0][1]['start'],
            'end':chunk[-1][1]['end'],
            'word_indices':[idx for idx,_ in chunk],
            'words':[c['word'] for _,c in chunk],
            'sentence':chunk[0][1]['sentence']
        })
SEG_JSON.write_text(json.dumps(segments,indent=2))

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

def current_scene(t):
    for s in scenes:
        if s['start'] <= t < s['end']: return s
    return scenes[-1]

def current_word_index(t):
    for i,c in enumerate(word_cues):
        if c['start'] <= t < c['end']: return i
    prev=[i for i,c in enumerate(word_cues) if c['start'] <= t]
    return prev[-1] if prev else 0

def current_segment(t, idx):
    for seg in segments:
        if seg['start'] <= t < seg['end'] and idx in seg['word_indices']:
            return seg
    for seg in segments:
        if idx in seg['word_indices']:
            return seg
    return segments[0]

def wrap_text(draw, text, font, max_width):
    words=text.split(); lines=[]; cur=''
    for w in words:
        test=(cur+' '+w).strip()
        if draw.textbbox((0,0),test,font=font)[2] <= max_width: cur=test
        else:
            if cur: lines.append(cur)
            cur=w
    if cur: lines.append(cur)
    return lines

def rounded(draw, box, fill, outline=None, width=2, radius=18):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)

def draw_centered(draw, y, text, font, fill, max_width=W-60, line_gap=6):
    lines=wrap_text(draw,text,font,max_width)
    heights=[draw.textbbox((0,0),l,font=font)[3]-draw.textbbox((0,0),l,font=font)[1] for l in lines]
    total=sum(heights)+line_gap*(len(lines)-1)
    yy=y-total/2
    for l,th in zip(lines,heights):
        bb=draw.textbbox((0,0),l,font=font); tw=bb[2]-bb[0]
        draw.text(((W-tw)/2,yy),l,font=font,fill=fill)
        yy += th+line_gap

def layout_segment_words(draw, seg):
    # Layout is based on the full segment and does NOT depend on active word.
    tokens=[]
    for idx,word in zip(seg['word_indices'],seg['words']):
        display=word.upper()
        bb=draw.textbbox((0,0),display,font=font_word)
        tokens.append({'idx':idx,'word':display,'w':bb[2]-bb[0],'h':bb[3]-bb[1]})
    rows=[[]]; curw=0
    for tok in tokens:
        add=tok['w']+17
        if curw+add > W-76 and rows[-1]:
            rows.append([]); curw=0
        rows[-1].append(tok); curw += add
    positions={}
    y=795 if len(rows)==1 else 778
    for row in rows[:2]:
        total=sum(tok['w'] for tok in row)+17*(len(row)-1)
        x=(W-total)/2
        maxh=max(tok['h'] for tok in row)
        for tok in row:
            positions[tok['idx']]={'x':x,'y':y,'word':tok['word'],'w':tok['w'],'h':tok['h']}
            x += tok['w']+17
        y += maxh+30
    return positions

def draw_caption_static_segment(draw,t):
    idx=current_word_index(t)
    seg=current_segment(t,idx)
    rounded(draw,(24,742,W-24,914),(13,17,28),(72,90,140),2,24)
    draw.text((42,755),'STATIC SUBTITLE SEGMENT  •  ACTIVE WORD HIGHLIGHT',font=font_tiny,fill=(145,165,210))
    positions=layout_segment_words(draw,seg)
    # Draw inactive words first at fixed positions.
    for wid,pos in positions.items():
        if wid != idx:
            draw.text((pos['x'],pos['y']),pos['word'],font=font_word,fill=(220,228,245))
    # Draw active word highlight on same fixed position; highlight moves word-to-word, layout remains stable.
    if idx in positions:
        pos=positions[idx]
        pulse=0.72+0.28*math.sin(t*18)
        fill=(245, int(185+50*pulse), 45)
        rounded(draw,(pos['x']-10,pos['y']-8,pos['x']+pos['w']+10,pos['y']+pos['h']+14),fill,(255,238,130),2,16)
        draw.text((pos['x'],pos['y']-1),pos['word'],font=font_word_active,fill=(15,17,24))

def draw_visual(draw,t,s):
    if s['id']=='s01_fail':
        rounded(draw,(28,190,250,560),(34,42,62),(76,104,160),2); rounded(draw,(290,190,512,560),(44,45,52),(190,76,76),2)
        draw.text((58,215),'TRANSCRIPT',font=font_small,fill=(160,190,255)); draw.text((323,215),'AI SUMMARY',font=font_small,fill=(255,190,190))
        for i in range(11): y=255+i*24; draw.line((55,y,215-(i%3)*22,y),fill=(92,112,150),width=4)
        for i in range(5): y=270+i*34; draw.line((322,y,475-(i%2)*50,y),fill=(125,125,135),width=5)
        rounded(draw,(330,440,475,500),(125,28,34),(255,90,90),3,10); draw.text((353,454),'USELESS',font=font_b,fill=(255,230,230)); draw.line((270,230,270,540),fill=(240,210,80),width=4)
    elif s['id']=='s02_bad_output':
        rounded(draw,(58,205,482,545),(32,36,48),(105,120,170),2,26); draw.text((92,235),'POLISHED SUMMARY',font=font_small,fill=(190,205,245))
        for i in range(3): y=295+i*48; rounded(draw,(90,y,450,y+28),(55,60,76),None,1,8); draw.line((105,y+14,340+i*25,y+14),fill=(115,125,150),width=5)
        for label,y in [('NO OWNER',444),('NO BLOCKER',482),('NO DECISION',520)]: draw.text((96,y),label,font=font_small,fill=(255,100,100)); draw.line((210,y+10,430,y+10),fill=(255,85,85),width=3)
    elif s['id']=='s03_mechanism':
        cx=270; box_y=395
        for i in range(9):
            x=50+(i%3)*120; y=205+(i//3)*70; rounded(draw,(x,y,x+92,y+38),(40,50,75),(85,105,150),2,9); draw.line((x+14,y+19,cx,box_y),fill=(90,110,150),width=2)
        rounded(draw,(175,385,365,500),(40,45,55),(245,196,55),3,20); draw.text((207,426),'GENERIC',font=font_b,fill=(235,235,235)); rounded(draw,(190,560,350,612),(50,80,55),(90,220,120),2,14); draw.text((207,573),'DECISION?',font=font_small,fill=(180,255,190)); draw.line((270,560,270,505),fill=(255,80,80),width=4)
    elif s['id']=='s04_fix':
        nodes=[('BLOCKERS',90),('OWNERS',195),('RISKS',300),('NEXT STEPS',405)]
        for i,(label,x) in enumerate(nodes):
            rounded(draw,(x,330,x+85,410),(38,65,65),(98,220,190),2,14); draw.text((x+8,365),label,font=font_tiny,fill=(210,255,245))
            if i>0: draw.line((nodes[i-1][1]+85,370,x,370),fill=(245,196,55),width=4)
        rounded(draw,(120,505,420,575),(30,55,45),(110,230,130),3,18); draw.text((155,526),'DECISION EVIDENCE',font=font_b,fill=(215,255,225))
    else:
        rounded(draw,(40,210,248,520),(54,45,52),(150,90,90),2,18); rounded(draw,(292,210,500,520),(35,58,50),(90,210,130),2,18)
        draw.text((92,240),'BEFORE',font=font_b,fill=(255,190,190)); draw.text((353,240),'AFTER',font=font_b,fill=(180,255,190))
        for i in range(5): draw.line((78,300+i*36,205,300+i*36),fill=(125,110,120),width=6)
        for i,label in enumerate(['owner','risk','next step']): y=310+i*50; rounded(draw,(330,y,460,y+28),(55,78,62),None,1,8); draw.text((350,y+5),label,font=font_tiny,fill=(200,255,210))
        draw.line((252,365,288,365),fill=(245,196,55),width=5); draw.polygon([(288,365),(274,356),(274,374)],fill=(245,196,55))

fourcc=cv2.VideoWriter_fourcc(*'mp4v')
writer=cv2.VideoWriter(str(SILENT), fourcc, FPS, (W,H))
for frame in range(int(DURATION*FPS)):
    t=frame/FPS
    img=Image.new('RGB',(W,H),(10,14,24)); draw=ImageDraw.Draw(img)
    for y in range(0,H,8): shade=18+int(18*y/H); draw.rectangle((0,y,W,y+8),fill=(shade,shade+4,shade+14))
    s=current_scene(t)
    rounded(draw,(18,18,522,78),(24,30,44),(70,90,140),2,18); draw.text((36,30),'PREVIEW / NOT FINAL',font=font_b,fill=(245,196,55)); draw.text((36,86),f"{s['id']}  •  {s['label']}",font=font_small,fill=(160,180,220))
    draw_centered(draw,145,s['title'],font_b,(235,240,255),W-50)
    draw_visual(draw,t,s)
    draw_centered(draw,645,s['desc'],font,(205,218,245),W-70)
    draw_caption_static_segment(draw,t)
    draw.rounded_rectangle((36,938,504,948),radius=5,fill=(50,55,70)); draw.rounded_rectangle((36,938,36+int(468*t/DURATION),948),radius=5,fill=(245,196,55))
    writer.write(np.array(img)[:,:,::-1])
writer.release()
subprocess.check_call(['ffmpeg','-y','-i',str(SILENT),'-i',str(AUDIO),'-map','0:v:0','-map','1:a:0','-c:v','libx264','-pix_fmt','yuv420p','-preset','veryfast','-crf','24','-c:a','aac','-b:a','160k','-t',str(DURATION),'-movflags','+faststart',str(MP4)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
meta={'candidate_id':'ai-output-autopsy-decision-summary-38s','version':'v2.0.0','script_hash':'72129542b86819fd','revision':'static_phrase_segments_active_word_highlight','mp4_path':str(MP4),'audio_path':str(AUDIO),'caption_segments_json_path':str(SEG_JSON),'audio_duration_seconds':ffprobe_duration(AUDIO),'preview_duration_seconds':ffprobe_duration(MP4),'sha256':hashlib.sha256(MP4.read_bytes()).hexdigest(),'caption_alignment':'static phrase segments; words stay fixed within each segment; only active word highlight moves left-to-right; forced alignment not used'}
META.write_text(json.dumps(meta,indent=2))
print(json.dumps(meta,indent=2))


terminal

Output:
{
  "candidate_id": "ai-output-autopsy-decision-summary-38s",
  "version": "v2.0.0",
  "script_hash": "72129542b86819fd",
  "revision": "static_phrase_segments_active_word_highlight",
  "mp4_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4",
  "audio_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/voiceover.mp3",
  "caption_segments_json_path": "/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/caption_static_segments_v3.json",
  "audio_duration_seconds": 34.968,
  "preview_duration_seconds": 38.0,
  "sha256": "0d33af98a9b8e1c7c69509f67a3762df36aa8af5eb202b7327f64529356c9c9a",
  "caption_alignment": "static phrase segments; words stay fixed within each segment; only active word highlight moves left-to-right; forced alignment not used"
}


OUT=/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s; for T in 15.2 16.0 17.0; do ffmpeg -y -ss $T -i "$OUT/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4" -frames:v 1 "$OUT/static_segment_${T}.jpg" >/dev/null 2>&1; done; file "$OUT/static_segment_16.0.jpg"; sha256sum "$OUT/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4"


Output:
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/static_segment_16.0.jpg: JPEG image data, JFIF standard 1.02, aspect ratio, density 1x1, segment length 16, comment: "Lavc60.31.102", baseline, precision 8, 540x960, components 3
0d33af98a9b8e1c7c69509f67a3762df36aa8af5eb202b7327f64529356c9c9a  /tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4


Question: Check caption style: do the subtitle words form a static phrase segment with multiple words visible and only one highlighted active word? Is it readable?


Image loaded into your context — you can see it natively now. Use your built-in vision to answer the user.

Question: Check caption style: do the subtitle words form a static phrase segment with multiple words visible and only one highlighted active word? Is it readable?


Ja — exakt. Ich habe es jetzt so korrigiert, wie Sie es beschreiben:

- Der **Subtitle-Abschnitt bleibt statisch**.
- Mehrere Wörter bleiben **an Ort und Stelle**.
- Nur das gerade gesprochene Wort bekommt das gelbe Highlight.
- Das Highlight wandert innerhalb desselben Abschnitts **von links nach rechts**.
- Erst beim nächsten Satz-/Phrase-Abschnitt wechselt der komplette Subtitle-Block.

Neue Preview:

MEDIA:/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4

## Neue Datei

```text
/tmp/autoshorts_preview_v2/ai-output-autopsy-decision-summary-38s/preview_ai-output-autopsy-decision-summary-38s_v3_static_caption_segments.mp4
```

SHA256:

```text
0d33af98a9b8e1c7c69509f67a3762df36aa8af5eb202b7327f64529356c9c9a
```

## Caption-Verhalten jetzt

Beispiel im geprüften Frame:

```text
WITHOUT A DECISION
TARGET AI
```

Dabei ist nur:

```text
TARGET
```

gelb hervorgehoben.

Die anderen Wörter bleiben sichtbar und statisch. Das ist nun viel näher an dem gewünschten Hormozi-/TikTok-Subtitle-Stil.

## Einschränkung

Das Timing ist weiterhin aus den vorhandenen zeitbasierten Word-Cues abgeleitet, noch kein echtes Forced Alignment. Aber das **Layout-Verhalten** ist jetzt korrekt:

> statischer Satzabschnitt, wanderndes Highlight.

Ich habe diese Präferenz auch dauerhaft aktualisiert, damit wir künftig nicht wieder in den „ein Wort hüpft allein über den Bildschirm“-Zirkus zurückfallen.


[M G] JARVIS, das Preview v3 mit statischen Caption-Segmenten ist nun deutlich besser. Wir gehen jetzt bewusst in genau einen finalen Pilot-Render, um die Gesamtqualität zu beurteilen.

Wichtig:
Dies ist eine Freigabe für einen finalen Pilot-Render, aber noch KEINE Freigabe für Posting oder Auto-Publishing.

Freigabe:

APPROVED_FOR_FINAL_RENDER ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd

Bitte führe jetzt Sprint 1.8 aus:

Final Pilot Render V1 — Wan 2.2 Quality Assessment

Ziel:
Erstelle genau ein finales Video mit Wan 2.2 für den Kandidaten:

ai-output-autopsy-decision-summary-38s
Version: v2.0.0
Script Hash: 72129542b86819fd

Dieses Video dient der Gesamtqualitätsbeurteilung:

* visuelle Qualität
* Motion
* Caption-Stil
* Timing
* Voiceover
* Hook-Wirkung
* Verständlichkeit
* Gesamtgefühl
* AI-Slop-Risiko

1. Vorher Sprint 1.7 / Caption-Fix sauber sichern

Bitte prüfe:

* git status
* pytest

Die Caption-Korrektur darf nicht nur als temporäres /tmp-Script existieren. Bitte integriere sie sauber in den eigentlichen Preview-/Caption-Renderer.

Pflicht-Caption-Stil ab jetzt:

* Subtitle-Abschnitt bleibt statisch.
* Mindestens 3 Wörter sind gleichzeitig sichtbar, ideal eher 4–7 Wörter.
* Die Wörter bleiben während des Abschnitts an Ort und Stelle.
* Nur das aktuell gesprochene Wort wird hervorgehoben.
* Das Highlight wandert von links nach rechts durch den Abschnitt.
* Erst beim nächsten Satz-/Phrase-Abschnitt wechselt der komplette Subtitle-Block.
* Kein einzelnes Wort allein auf dem Bildschirm, ausser es ist bewusst als Hook/Impact-Frame geplant.
* Keine hektisch komplett wechselnden Wortgruppen.

Bitte ergänze Tests oder Snapshot-/Logic-Tests für:

* static caption segment contains multiple words
* active word changes within same segment
* segment text remains stable while highlight moves
* no one-word-only caption mode as default

Dann committen:

Commit Message:
Add static active-word caption segments for preview rendering

2. Finalrender-Gate prüfen

Vor Wan-Render bitte prüfen:

* candidate_id stimmt
* version stimmt
* script_hash stimmt
* approval_state = APPROVED_FOR_FINAL_RENDER
* claim gate valid
* AI disclosure valid
* publishing bleibt blockiert
* platform API bleibt blockiert

Wenn eines davon nicht stimmt:
nicht rendern, sondern blockieren und berichten.

3. Finales Voiceover / Timing

Bitte nutze den bisherigen Voiceover-Text, ausser du erkennst einen klaren kleinen Timing-Fehler.

Finaler Voiceover-Text:

Your AI summary is useless because it has no decision target.

Look at the bad output: it sounds polished, but it missed the one thing the viewer needed.

The summary says “great discussion” but misses the launch decision.

Without a decision target, AI compresses every sentence equally.

Ask for blockers, owners, risks, and next steps tied to the decision.

Before: generic summary. After: decision evidence with owner and risk.

Don’t ask for a summary. Ask for decision evidence.

Wenn möglich:

* TTS neu erzeugen oder vorhandenes TTS sauber verwenden.
* Audio-Dauer exakt messen.
* Captions neu gegen Audio ausrichten.
* Falls Forced Alignment verfügbar ist: verwenden.
* Falls Forced Alignment nicht verfügbar ist: bestmögliche Segment-/Word-Timing-Approximation verwenden und als Einschränkung markieren.

4. Wan 2.2 final rendern

Bitte genau einen finalen Wan-2.2-Render erzeugen.

Keine Variantenserie.
Keine 5 Alternativen.
Keine unnötigen Styleframe-Batches.

Wan-Regeln:

* Wan erzeugt nur textfreie visuelle Motion-/Background-Ebene.
* Keine eingebrannten Texte.
* Keine Logos.
* Keine Captions.
* Keine Zahlen/Fakten.
* Keine UI-Texte.
* Keine Marken.
* Alle Texte, Labels, Pfeile, Stempel und Captions bleiben renderer-owned.

Nutze den bestehenden ShotPlan:

s01_fail — ca. 5s
Split-screen Transcript vs. useless summary
Hook / USELESS / PREVIEW-Hinweis im finalen Video natürlich entfernen.

s02_bad_output — ca. 7s
Polished bad summary without decision evidence

s03_mechanism — ca. 9s
Notes compressed equally into generic summary

s04_fix — ca. 9s
Route from messy notes to blockers / owners /
risks / next steps

s05_takeaway — ca. 8s
Before/After contrast and final takeaway

Zieldauer:
ca. 35–40 Sekunden.

Format:

* 9:16
* final möglichst 1080x1920
* 30 fps, falls Pipeline stabil
* saubere mobile Lesbarkeit
* keine Preview-/Not-Final-Einblendung mehr

5. Overlays und Captions

Bitte im finalen Video:

* statische Caption-Segmente mit wanderndem Active-Word-Highlight
* mobile-first Safe Zones beachten
* Hook in den ersten 1–2 Sekunden klar
* USELESS-Stempel / Pfeile / Labels renderer-owned
* Takeaway am Ende klar lesbar
* keine überladenen Textflächen
* Captions nicht zu tief, damit Plattform-UI sie nicht verdeckt

6. Final Review Package erzeugen

Nach dem Render bitte erstellen:

* final MP4 path
* SHA256
* duration
* resolution
* fps
* audio path
* subtitle/caption path
* render manifest
* Wan prompt log / prompt plan
* claim gate result
* AI disclosure result
* resource gate result
* known limitations
* final social copy draft
* manual posting pack draft

Wichtig:
Manual Posting Pack nur als Draft. Noch kein Posting.

7. Telegram Delivery

Wenn Telegram-Versand stabil funktioniert:
Sende mir das finale MP4 per Telegram mit Kurzreview.

Wenn Telegram nicht klappt:
Berichte lokalen Pfad, SHA256 und Delivery Plan.

8. Nach dem Render NICHT posten

Explizit nicht ausführen:

* keine YouTube-API
* keine TikTok-API
* keine Instagram-API
* keine LinkedIn-API
* kein Auto-Posting
* kein Scheduling
* kein zweiter Wan-Render ohne neue Freigabe

9. Abschlussbericht

Bitte kurz berichten:

* git status
* Commit Hash für Caption-Fix / Sprint 1.7, falls erfolgt
* pytest Ergebnis
* Finalrender durchgeführt: yes/no
* MP4-Pfad
* SHA256
* Dauer
* Auflösung
* FPS
* Caption-Modus
* Forced Alignment ja/nein
* bekannte Timing-Einschränkungen
* AI Disclosure
* Claim Gate
* Resource Gate
* Telegram-Versand erfolgreich ja/nein
* nächstmögliche Kommandos

10. Nächste Review-Kommandos nach Sichtung

Wenn ich kleine Korrekturen will:

REVISE_FINAL ai-output-autopsy-decision-summary-38s <field> "Änderungswunsch"

Wenn das Video uploadbereit ist:

APPROVED_FOR_MANUAL_POSTING ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd

Wenn ein zweiter Finalrender nötig ist:

REQUEST_SECOND_FINAL_RENDER ai-output-autopsy-decision-summary-38s "Begründung"

Wenn ich es ablehne:

REJECT_FINAL ai-output-autopsy-decision-summary-38s "Grund"

Wichtigster Grundsatz:
Genau ein finaler Pilot-Render ist jetzt erlaubt. Kein Posting. Keine Render-Serie. Danach prüfen wir gemeinsam die Gesamtqualität.