     1|from __future__ import annotations
     2|
     3|import hashlib, json, math, os, re, subprocess, sys
     4|from pathlib import Path
     5|
     6|sys.path.insert(0, '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot')
     7|from PIL import Image, ImageDraw, ImageFont, ImageFilter
     8|from autoshorts.rendering.static_captions import (
     9|    CaptionLayoutConfig,
    10|    WordCue,
    11|    active_word_index_at,
    12|    build_safe_caption_segments,
    13|    layout_caption_segment,
    14|    segment_for_active_word,
    15|)
    16|
    17|BASE = Path('/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s')
    18|OUT = BASE / 'preview_v2_caption_safe'
    19|FRAMES = OUT / 'frames'
    20|QA = OUT / 'qa_frames'
    21|for p in [OUT, FRAMES, QA]: p.mkdir(parents=True, exist_ok=True)
    22|AUDIO = BASE / 'audio/voiceover.mp3'
    23|SRT = BASE / 'audio/edge_subtitles.srt'
    24|CANDIDATE = 'digital-red-flags-fake-invoice-payment-route-30s'
    25|W,H,FPS = 1080,1920,24
    26|DURATION = 31.291667
    27|SAFE = CaptionLayoutConfig(video_width=W, video_height=H, horizontal_margin_px=70, bottom_margin_px=310, max_width_ratio=0.86, font_size_px=66, min_font_size_px=48, word_gap_px=18)
    28|FONT_BOLD='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
    29|FONT_REG='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
    30|
    31|def font(size, bold=True):
    32|    return ImageFont.truetype(FONT_BOLD if bold else FONT_REG, size)
    33|
    34|def parse_time(t):
    35|    h,m,rest=t.split(':'); s,ms=rest.split(',')
    36|    return int(h)*3600+int(m)*60+int(s)+int(ms)/1000
    37|
    38|def parse_srt(path):
    39|    txt=path.read_text(encoding='utf-8')
    40|    blocks=re.split(r'\n\s*\n', txt.strip())
    41|    out=[]
    42|    for b in blocks:
    43|        lines=b.splitlines()
    44|        if len(lines)>=3:
    45|            m=re.match(r'(\d\d:\d\d:\d\d,\d\d\d) --> (\d\d:\d\d:\d\d,\d\d\d)', lines[1])
    46|            if m: out.append((parse_time(m.group(1)), parse_time(m.group(2)), ' '.join(lines[2:])))
    47|    return out
    48|
    49|def clean_words(text):
    50|    return re.findall(r"[A-Za-z0-9]+(?:[-–—'][A-Za-z0-9]+)*(?:\.com)?|[A-Za-z0-9.-]+\.com", text.replace('’',"'"))
    51|
    52|def build_cues():
    53|    cues=[]
    54|    for start,end,text in parse_srt(SRT):
    55|        words=clean_words(text)
    56|        weights=[max(0.55, len(w)/5) for w in words]
    57|        total=sum(weights) or 1
    58|        t=start
    59|        for w,wt in zip(words,weights):
    60|            dur=(end-start)*wt/total
    61|            cues.append(WordCue(start=t, end=t+dur, word=w, sentence=text))
    62|            t+=dur
    63|    return tuple(cues)
    64|
    65|WORD_CUES=build_cues()
    66|SEGMENTS=build_safe_caption_segments(WORD_CUES, layout=SAFE, min_words=2, target_words=5, max_words=7)
    67|
    68|def rounded(draw, box, r, fill, outline=None, width=1):
    69|    draw.rounded_rectangle(box, radius=r, fill=fill, outline=outline, width=width)
    70|
    71|def text(draw, xy, s, size, fill, bold=True, anchor=None, align='left'):
    72|    draw.text(xy, s, font=font(size,bold), fill=fill, anchor=anchor, align=align)
    73|
    74|def scene_for(t):
    75|    if t < 5.8: return 'hook'
    76|    if t < 11.9: return 'same'
    77|    if t < 15.2: return 'route'
    78|    if t < 20.9: return 'checks'
    79|    if t < 26.9: return 'verify'
    80|    return 'takeaway'
    81|
    82|def bg(draw):
    83|    draw.rectangle((0,0,W,H), fill=(14,18,29))
    84|    for y in range(0,H,6):
    85|        c=int(18+18*y/H)
    86|        draw.line((0,y,W,y), fill=(c,c+4,c+14))
    87|    draw.rectangle((0,0,W,H), outline=(40,51,73), width=8)
    88|
    89|def draw_email_invoice(draw, t, mode):
    90|    # phone/browser screen card
    91|    rounded(draw, (82,170,998,1330), 44, (235,239,244), (78,92,122), 3)
    92|    rounded(draw, (118,220,962,420), 26, (255,255,255), (218,224,234), 2)
    93|    text(draw,(150,252),'Inbox',38,(39,48,70))
    94|    text(draw,(150,314),'From: billing@sample.test',30,(78,88,110),False)
    95|    urgency=(236,165,37) if mode in ['same','route'] else (113,124,148)
    96|    text(draw,(150,362),'Subject: Payment due today',32,urgency)
    97|    # invoice doc
    98|    rounded(draw, (150,470,930,1198), 24, (250,251,253), (199,207,222), 2)
    99|    text(draw,(196,522),'INVOICE',52,(38,48,66))
   100|    text(draw,(196,590),'Generic Services',31,(104,115,136),False)
   101|    for yy in [670,735,800]:
   102|        draw.rounded_rectangle((196,yy,730,yy+28),10, fill=(224,230,240))
   103|    draw.rounded_rectangle((196,872,884,1012),20, fill=(240,244,249), outline=(210,218,231), width=2)
   104|    text(draw,(224,905),'Payment route',34,(54,64,86))
   105|    changed = mode in ['route','checks','verify']
   106|    route_color=(207,54,54) if changed else (60,120,90)
   107|    route_txt='NEW ROUTE' if changed else 'KNOWN ROUTE'
   108|    rounded(draw,(224,956,538,1006),16,(255,247,238) if changed else (235,248,241),route_color,3)
   109|    text(draw,(250,966),route_txt,29,route_color)
   110|    if changed:
   111|        draw.line((610,970,830,970), fill=(207,54,54), width=7)
   112|        draw.polygon([(830,970),(800,950),(800,990)], fill=(207,54,54))
   113|        text(draw,(224,1060),'Route changed',42,(207,54,54))
   114|    else:
   115|        text(draw,(224,1060),'Looks normal',42,(60,120,90))
   116|
   117|def draw_checks(draw, t):
   118|    labels=[('sender domain',15.3),('urgency language',16.9),('payment route',18.4)]
   119|    y=1190
   120|    for i,(lab,ts) in enumerate(labels):
   121|        active=t>=ts
   122|        col=(247,202,76) if active else (122,133,155)
   123|        rounded(draw,(155,y+i*92,925,y+64+i*92),22,(35,43,61),(col if active else (74,86,110)),3)
   124|        text(draw,(205,y+13+i*92),f'{i+1}. {lab}',33,(255,255,255) if active else (188,197,214))
   125|
   126|def draw_verify(draw, t):
   127|    rounded(draw,(150,1190,930,1385),30,(32,42,61),(88,104,137),3)
   128|    text(draw,(190,1235),'Use a second channel',39,(255,255,255))
   129|    text(draw,(190,1300),'before paying',36,(247,202,76))
   130|    # phone/check icon
   131|    rounded(draw,(730,1240,862,1342),18,(18,24,36),(247,202,76),3)
   132|    draw.line((760,1298,790,1325,836,1266), fill=(247,202,76), width=9)
   133|
   134|def draw_takeaway(draw):
   135|    rounded(draw,(92,420,988,1010),44,(24,32,48),(247,202,76),4)
   136|    text(draw,(540,565),'Urgency is not proof.',64,(255,255,255),anchor='mm')
   137|    text(draw,(540,700),"It's the red flag.",76,(247,202,76),anchor='mm')
   138|    text(draw,(540,850),'Save this before paying from email.',36,(198,207,224),anchor='mm')
   139|
   140|def draw_caption(draw, t):
   141|    active=active_word_index_at(WORD_CUES,t)
   142|    seg=segment_for_active_word(SEGMENTS,active)
   143|    active_word=WORD_CUES[active].word
   144|    layout=layout_caption_segment(seg.words, active_word=active_word, config=SAFE)
   145|    f=font(layout.font_size_px, True)
   146|    # subtle shadow/outline no box/bar
   147|    flat_words=[]
   148|    for line in layout.lines: flat_words.extend(line)
   149|    boxes=list(layout.word_boxes)
   150|    for word,box in zip(flat_words, boxes):
   151|        x,y,_,_=box
   152|        fill=(247,202,76) if word==active_word else (255,255,255)
   153|        for dx,dy in [(-2,2),(2,2),(0,3)]:
   154|            draw.text((x+dx,y+dy), word, font=f, fill=(0,0,0,160), stroke_width=2, stroke_fill=(0,0,0,130))
   155|        draw.text((x,y), word, font=f, fill=fill, stroke_width=2, stroke_fill=(6,10,18))
   156|
   157|def draw_frame(t):
   158|    img=Image.new('RGB',(W,H),(14,18,29)); draw=ImageDraw.Draw(img,'RGBA')
   159|    bg(draw)
   160|    sc=scene_for(t)
   161|    if sc=='takeaway':
   162|        draw_takeaway(draw)
   163|    else:
   164|        draw_email_invoice(draw,t, 'route' if sc in ['route','checks','verify'] else sc)
   165|        if sc=='same':
   166|            # No extra renderer text card here; the safe captions carry the phrase.
   167|            pass
   168|        if sc in ['checks','verify']: draw_checks(draw,t)
   169|        if sc=='verify': draw_verify(draw,t)
   170|    # safe guides very subtle? don't include in final
   171|    draw_caption(draw,t)
   172|    return img
   173|
   174|# render raw frames to ffmpeg
   175|silent=OUT/f'{CANDIDATE}_preview_v2_caption_safe_silent.mp4'
   176|final=OUT/f'{CANDIDATE}_preview_v2_caption_safe.mp4'
   177|cmd=['ffmpeg','-y','-f','rawvideo','-vcodec','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-an','-c:v','libx264','-pix_fmt','yuv420p','-crf','20',str(silent)]
   178|proc=subprocess.Popen(cmd, stdin=subprocess.PIPE)
   179|frames=int(math.ceil(DURATION*FPS))
   180|for i in range(frames):
   181|    t=i/FPS
   182|    proc.stdin.write(draw_frame(t).tobytes())
   183|proc.stdin.close(); proc.wait()
   184|if proc.returncode: raise SystemExit(proc.returncode)
   185|subprocess.run(['ffmpeg','-y','-i',str(silent),'-i',str(AUDIO),'-c:v','copy','-c:a','aac','-shortest',str(final)], check=True)
   186|# QA frames and contact sheet
   187|sample_times=[1,4,10,15.8,17.8,20.2,24.5,28.2,30.2]
   188|thumbs=[]
   189|for ts in sample_times:
   190|    im=draw_frame(ts)
   191|    p=QA/f'frame_{str(ts).replace(".","_")}s.jpg'
   192|    im.save(p, quality=92)
   193|    thumbs.append((ts, im.resize((270,480))))
   194|sheet=Image.new('RGB',(810,1440),(12,16,24)); sd=ImageDraw.Draw(sheet)
   195|for idx,(ts,thumb) in enumerate(thumbs):
   196|    x=(idx%3)*270; y=(idx//3)*480
   197|    sheet.paste(thumb,(x,y)); sd.rectangle((x,y,x+269,y+479),outline=(80,90,115),width=2)
   198|    sd.text((x+12,y+12),f'{ts:.1f}s',font=font(24),fill=(247,202,76))
   199|contact=OUT/'digital-red-flags_caption_safe_contact_sheet_v2.jpg'
   200|sheet.save(contact, quality=92)
   201|

Use offset=201 to continue reading (showing 1-200 of 234 lines)


Explanation:
**Exploring video script adaptation**

I'm considering adapting a render script for the next video with TTS, possibly using EdgeTTS. I need to check if EdgeTTS is available first. It seems like generating a preview could work, and since the user's message is text rather than voice, using TTS for the video seems fine. Maybe I should look at using the edge-tts command or check if the edge-tts skill is relevant to TTS. I'll load that up.


edge-tts

---
name: edge-tts
description: "Text-to-speech using Microsoft Edge TTS. Supports multiple voices including German male/female options. Used for voice messages and audio output."
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
  hermes:
    tags: [TTS, Voice, Audio, Edge]
    related_skills: [sherpa-onnx-tts]
---

# Edge TTS

Text-to-speech using Microsoft Edge TTS (`edge-tts` Python package). No API key required — uses Microsoft's free Edge browser TTS engine.

## When to Use

Use this skill when:
- You need to convert text to speech for voice messages
- You want to test or compare different TTS voices
- The user asks for a voice message or speech output
- TTS configuration needs to be changed

## Prerequisites

```bash
pip install edge-tts
```

## User Voice Preference

**Default voice: `de-DE-KillianNeural`** (German male, natural, clear)
Set in `~/.hermes/config.yaml` → `tts.edge.voice`.

## German Voices Available

| Voice | Gender | Style | Description |
|-------|--------|-------|-------------|
| `de-DE-KillianNeural` | Male | Natural | Clear, natural, slightly youthful (DEFAULT) |
| `de-DE-ConradNeural` | Male | Friendly | Warm, positive, slightly melodic |
| `de-DE-FlorianMultilingualNeural` | Male | Professional | Clear, neutral, professional |
| `de-DE-AmalaNeural` | Female | Friendly | Positive, warm |
| `de-DE-KatjaNeural` | Female | Friendly | Positive, clear |
| `de-DE-SeraphinaMultilingualNeural` | Female | General | Natural, clear |

## Usage

### Basic Synthesis

```bash
edge-tts --voice de-DE-ConradNeural --text "Guten Tag, Sir." --write-media output.mp3
```

### Testing a Voice

```bash
edge-tts --voice de-DE-FlorianMultilingualNeural --text "Testnachricht" --write-media /tmp/test.mp3
```

### Listing Available Voices

```bash
python3 -m edge_tts --list-voices | grep "de-DE"
```

### Configuring in Hermes

Update `~/.hermes/config.yaml`:

```yaml
tts:
  provider: edge
  edge:
    voice: de-DE-ConradNeural
```

## Pitfalls

- **Voice compatibility**: Not all voices work with all text. German text requires a German voice (`de-DE-*`).
- **File format**: Edge TTS outputs MP3 by default. Convert to OGG for Telegram voice messages if needed: `ffmpeg -i input.mp3 -codec:a libopus -b:a 128k output.ogg`
- **Gateway restart**: After changing the TTS voice in config, restart the gateway for changes to take effect.
- **Character limits**: Very long texts may produce unusually long audio. Split long messages before TTS.
- **Config verification**: After updating `tts.edge.voice` in config, always verify the change took effect by reading the config back. YAML writes can silently fail or be overwritten by subsequent edits. Check the TTS response field `voice_compatible: true` — if `false`, the voice may not have been applied.
- **Voice switching workflow**: Always test a voice with `edge-tts --voice <voice> --text "Test" --write-media /tmp/test.mp3` before committing to it. Multiple voices can sound similar; listening is the only reliable way to choose.
- **Config persistence**: Python one-liner YAML edits may not persist — always verify with a separate read-back step.

## Shorts / AutoShorts Baseline Voice Use

For short-form content pipelines where the GPU is needed for ComfyUI/Wan/Qwen image or video generation, use EdgeTTS as the first baseline voice because it does not consume local GPU resources and is quick to iterate.

Recommended first English A/B voices for a precise, non-guru AI/workflow channel tone:

```bash
edge-tts --voice en-GB-RyanNeural --text "Most AI automations do not fail dramatically. They quietly rot." --write-media /tmp/ryan.mp3
edge-tts --voice en-US-ChristopherNeural --text "Most AI automations do not fail dramatically. They quietly rot." --write-media /tmp/christopher.mp3
```

For AutoShorts-style production code, keep EdgeTTS behind an explicit adapter boundary:

```text
TTSProviderPlan(edge_tts, voice)
→ EdgeTTSRequest(text, output_path.mp3)
→ edge-tts CLI call
→ EdgeTTSResult(side_effects=("call_edge_tts", "write_audio"), external_calls=("edge-tts",))
```

Then mux/burn captions in a separate FFmpeg boundary:

```text
MP4 timing preview + EdgeTTS MP3 + renderer-owned ASS subtitles
→ FFmpeg audio/caption review MP4
```

Important FFmpeg pitfall: do not blindly use `-shortest` when adding audio to a longer timing preview. It can silently truncate the video to the shorter voiceover duration. Preserve the planned video duration unless the feature explicitly asks to trim to audio length.

Compare against any external/cloud TTS such as NVIDIA Magpie with the same 15–20 second script sample. Score clarity, pronunciation of AI/workflow terms, retention energy, artifacts, latency, cost, and operational reliability. Whisper is not a TTS model; use it later for transcript/alignment QA only.

## Linked Resources

- `references/german-voice-comparison.md` — German male voice comparison data from session 2026-05-07, with recommendations for voice selection.
- `references/autoshorts-edge-tts-audio-caption.md` — EdgeTTS boundary pattern for AutoShorts audio/caption review MP4s, including ffprobe duration alignment and the `-shortest` pitfall.

## Voice Switching Workflow

1. List available voices: `python3 -m edge_tts --list-voices | grep "de-DE"`
2. Test candidate voice: `edge-tts --voice <voice> --text "Testnachricht" --write-media /tmp/test.mp3`
3. Listen and decide
4. Update config: `tts.edge.voice: <chosen_voice>`
5. Restart gateway

## Related Skills

- `sherpa-onnx-tts` — Offline TTS with local German female voice (Eva). No internet required.

To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'


Explanation:
**Considering video content**

I need to create a preview video and decide on the theme. Perhaps I'll go with "Calendar invite trap: meeting link vs domain" since using a QR code might lead to issues. I can develop a script warning viewers about the traps in invites that look normal but may actually lead to fake login pages. Before clicking, I want to emphasize checking the sender, link domain, and unusual login requests, reinforcing the idea that a calendar invite is just a request, not validation.
**Exploring video preview options**

I’m trying to figure out how to render a preview with mockups and active word captions. I might use Edge TTS with an English voice for text-to-speech (TTS) but need to ensure it has exact timing and forced alignment. It seems Edge TTS can write subtitles, potentially in .vtt format, which could help with precise word timing. I should also look into existing functions for static captions to integrate any existing code I find. I want to ensure I get the word boundaries right for better alignment.
**Utilizing edge_tts for audio and captions**

I'm looking into using Python's edge_tts to take advantage of its WordBoundary events. This could help me generate MP3 files with precise word cues for timing in speech. I plan to create a script using edge_tts asynchronously, saving audio chunks along with their word boundary offsets and durations. This should improve the caption rendering process significantly. First, though, I need to check if the necessary package is installed. I've got to ensure everything's set up correctly!

terminal

python - <<'PY'
import importlib.util
print('edge_tts', importlib.util.find_spec('edge_tts') is not None)
print('PIL', importlib.util.find_spec('PIL') is not None)
PY


Output:
edge_tts True
PIL True


Explanation:
**Considering audio captioning**

I'm focusing on audio that lasts about 33 seconds. I need to ensure I'm using static captions generated from word cue objects. For this, I'll use WordCue and apply the `build_safe_caption_segments` function with parameters like a minimum of 3 words, targeting 5, and a maximum of 7. It's essential to avoid big bars and to establish a safe zone. I'm also questioning if I need to check the read functions API for further information.


     1|"""Static active-word caption segment logic.
     2|
     3|Caption rule for TrueTraceShorts previews/finals:
     4|- A subtitle phrase segment stays fixed on screen.
     5|- At least three words are visible per default segment whenever possible.
     6|- Only the active spoken word changes highlight.
     7|- The highlight moves left-to-right until the next phrase segment replaces it.
     8|- Caption layout must remain inside the mobile safe zone.
     9|"""
    10|
    11|from __future__ import annotations
    12|
    13|from dataclasses import dataclass
    14|from typing import Sequence
    15|
    16|
    17|@dataclass(frozen=True)
    18|class WordCue:
    19|    start: float
    20|    end: float
    21|    word: str
    22|    sentence: str = ""
    23|
    24|
    25|@dataclass(frozen=True)
    26|class StaticCaptionSegment:
    27|    start: float
    28|    end: float
    29|    word_indices: tuple[int, ...]
    30|    words: tuple[str, ...]
    31|    sentence: str = ""
    32|
    33|    @property
    34|    def text(self) -> str:
    35|        return " ".join(self.words)
    36|
    37|
    38|@dataclass(frozen=True)
    39|class CaptionLayoutConfig:
    40|    video_width: int = 1080
    41|    video_height: int = 1920
    42|    horizontal_margin_px: int = 60
    43|    bottom_margin_px: int = 260
    44|    max_width_ratio: float = 0.88
    45|    max_lines: int = 2
    46|    font_size_px: int = 68
    47|    min_font_size_px: int = 46
    48|    line_gap_px: int = 14
    49|    word_gap_px: int = 18
    50|    highlight_pad_x_px: int = 10
    51|    highlight_pad_y_px: int = 5
    52|
    53|    @property
    54|    def safe_width_px(self) -> int:
    55|        ratio_width = int(self.video_width * self.max_width_ratio)
    56|        margin_width = self.video_width - 2 * self.horizontal_margin_px
    57|        return min(ratio_width, margin_width)
    58|
    59|
    60|@dataclass(frozen=True)
    61|class CaptionLayout:
    62|    lines: tuple[tuple[str, ...], ...]
    63|    font_size_px: int
    64|    left_px: int
    65|    right_px: int
    66|    top_px: int
    67|    bottom_px: int
    68|    width_px: int
    69|    height_px: int
    70|    word_boxes: tuple[tuple[int, int, int, int], ...]
    71|    active_word_box: tuple[int, int, int, int] | None
    72|
    73|
    74|def build_static_caption_segments(
    75|    word_cues: Sequence[WordCue],
    76|    *,
    77|    min_words: int = 3,
    78|    target_words: int = 6,
    79|    max_words: int = 7,
    80|) -> tuple[StaticCaptionSegment, ...]:
    81|    if min_words < 2:
    82|        raise ValueError("min_words must be >= 2")
    83|    if target_words < min_words:
    84|        raise ValueError("target_words must be >= min_words")
    85|    if max_words < target_words:
    86|        raise ValueError("max_words must be >= target_words")
    87|
    88|    grouped: list[list[tuple[int, WordCue]]] = []
    89|    current_sentence: str | None = None
    90|    current: list[tuple[int, WordCue]] = []
    91|    for index, cue in enumerate(word_cues):
    92|        sentence_key = cue.sentence or "__no_sentence__"
    93|        if current and sentence_key != current_sentence:
    94|            grouped.append(current)
    95|            current = []
    96|        current_sentence = sentence_key
    97|        current.append((index, cue))
    98|    if current:
    99|        grouped.append(current)
   100|
   101|    segments: list[StaticCaptionSegment] = []
   102|    for group in grouped:
   103|        chunks: list[list[tuple[int, WordCue]]] = []
   104|        i = 0
   105|        while i < len(group):
   106|            remaining = len(group) - i
   107|            size = min(target_words, remaining)
   108|            if remaining < min_words and chunks:
   109|                chunks[-1].extend(group[i:])
   110|                break
   111|            if size < min_words and len(group) >= min_words:
   112|                start = max(0, len(group) - min_words)
   113|                chunks.append(group[start:])
   114|                break
   115|            chunks.append(group[i : i + size])
   116|            i += size
   117|        for chunk in chunks:
   118|            if not chunk:
   119|                continue
   120|            if len(chunk) > max_words:
   121|                # Split oversized merged chunks conservatively while preserving min_words.
   122|                while len(chunk) > max_words:
   123|                    head = chunk[:max_words]
   124|                    segments.append(_segment_from_chunk(head))
   125|                    chunk = chunk[max_words:]
   126|                if 0 < len(chunk) < min_words and segments:
   127|                    previous = segments.pop()
   128|                    prev_pairs = [(idx, word_cues[idx]) for idx in previous.word_indices]
   129|                    chunk = prev_pairs + chunk
   130|            segments.append(_segment_from_chunk(chunk))
   131|    return tuple(segments)
   132|
   133|
   134|def build_safe_caption_segments(
   135|    word_cues: Sequence[WordCue],
   136|    *,
   137|    layout: CaptionLayoutConfig | None = None,
   138|    min_words: int = 3,
   139|    target_words: int = 6,
   140|    max_words: int = 7,
   141|) -> tuple[StaticCaptionSegment, ...]:
   142|    """Build phrase segments that are pre-split to fit the caption safe zone.
   143|
   144|    The function keeps the established 3–7 word phrase behavior, but refuses to
   145|    create a segment that would need more than two lines or exceed the safe
   146|    width at the minimum allowed font size. Very long tokens are handled by
   147|    dynamic shrink in layout, and if a phrase is still too wide the segment is
   148|    split earlier.
   149|    """
   150|    cfg = layout or CaptionLayoutConfig()
   151|    base_segments = build_static_caption_segments(
   152|        word_cues,
   153|        min_words=min_words,
   154|        target_words=target_words,
   155|        max_words=max_words,
   156|    )
   157|    safe_segments: list[StaticCaptionSegment] = []
   158|    for segment in base_segments:
   159|        pairs = [(idx, word_cues[idx]) for idx in segment.word_indices]
   160|        current: list[tuple[int, WordCue]] = []
   161|        for pair in pairs:
   162|            candidate = current + [pair]
   163|            candidate_words = tuple(cue.word for _, cue in candidate)
   164|            if len(candidate_words) <= max_words and _fits_caption_layout(candidate_words, cfg):
   165|                current = candidate
   166|                continue
   167|            if current:
   168|                safe_segments.append(_segment_from_chunk(current))
   169|                current = [pair]
   170|            else:
   171|                current = [pair]
   172|        if current:
   173|            # Try to merge a too-small tail back only when still safe.
   174|            if (
   175|                len(current) < min_words
   176|                and safe_segments
   177|                and len(safe_segments[-1].words) + len(current) <= max_words
   178|            ):
   179|                prev = safe_segments[-1]
   180|                merged_pairs = [(idx, word_cues[idx]) for idx in prev.word_indices] + current
   181|                merged_words = tuple(cue.word for _, cue in merged_pairs)
   182|                if _fits_caption_layout(merged_words, cfg):
   183|                    safe_segments[-1] = _segment_from_chunk(merged_pairs)
   184|                else:
   185|                    safe_segments.append(_segment_from_chunk(current))
   186|            else:
   187|                safe_segments.append(_segment_from_chunk(current))
   188|    return tuple(safe_segments)
   189|
   190|
   191|def _segment_from_chunk(chunk: Sequence[tuple[int, WordCue]]) -> StaticCaptionSegment:
   192|    return StaticCaptionSegment(
   193|        start=chunk[0][1].start,
   194|        end=chunk[-1][1].end,
   195|        word_indices=tuple(index for index, _ in chunk),
   196|        words=tuple(cue.word for _, cue in chunk),
   197|        sentence=chunk[0][1].sentence,
   198|    )
   199|
   200|
   201|def active_word_index_at(word_cues: Sequence[WordCue], timestamp: float) -> int:
   202|    for index, cue in enumerate(word_cues):
   203|        if cue.start <= timestamp < cue.end:
   204|            return index
   205|    previous = [index for index, cue in enumerate(word_cues) if cue.start <= timestamp]
   206|    return previous[-1] if previous else 0
   207|
   208|
   209|def segment_for_active_word(segments: Sequence[StaticCaptionSegment], active_word_index: int) -> StaticCaptionSegment:
   210|    for segment in segments:
   211|        if active_word_index in segment.word_indices:
   212|            return segment
   213|    raise ValueError(f"active word index {active_word_index} is not present in any segment")
   214|
   215|
   216|def caption_state_at(word_cues: Sequence[WordCue], segments: Sequence[StaticCaptionSegment], timestamp: float) -> dict[str, object]:
   217|    active_index = active_word_index_at(word_cues, timestamp)
   218|    segment = segment_for_active_word(segments, active_index)
   219|    return {
   220|        "segment_text": segment.text,
   221|

Use offset=221 to continue reading (showing 1-220 of 360 lines)


from __future__ import annotations

import asyncio, hashlib, json, math, re, subprocess, sys
from pathlib import Path

sys.path.insert(0, '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot')
from PIL import Image, ImageDraw, ImageFont
import edge_tts
from autoshorts.rendering.static_captions import (
    CaptionLayoutConfig,
    WordCue,
    active_word_index_at,
    build_safe_caption_segments,
    layout_caption_segment,
    segment_for_active_word,
)

BASE = Path('/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s')
AUDIO_DIR = BASE / 'audio'
OUT = BASE / 'preview_v1_word_aligned'
QA = OUT / 'qa_frames'
for p in [BASE, AUDIO_DIR, OUT, QA]:
    p.mkdir(parents=True, exist_ok=True)

CANDIDATE = 'calendar-invite-login-trap-30s'
VERSION = 'v1.0.0-word-aligned-proof'
VOICE = 'en-GB-RyanNeural'
W, H, FPS = 1080, 1920, 24
FONT_BOLD = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
FONT_REG = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
SAFE = CaptionLayoutConfig(
    video_width=W,
    video_height=H,
    horizontal_margin_px=70,
    bottom_margin_px=300,
    max_width_ratio=0.86,
    font_size_px=66,
    min_font_size_px=48,
    word_gap_px=18,
)

SCRIPT = (
    "This calendar invite looks normal. The trap is the login button. "
    "Same meeting title. Same guest list. Same urgent tone. "
    "But the link points somewhere else. Before you sign in, check three things: "
    "the sender, the link domain, and whether the login asks for more than usual. "
    "If the domain feels off, do not sign in from the invite. Open the service yourself. "
    "The fix is simple: trust the app you opened, not the button that rushed you."
)
SENTENCES = [s.strip() for s in re.split(r'(?<=[.!?])\s+', SCRIPT) if s.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 font(size: int, bold: bool = True):
    return ImageFont.truetype(FONT_BOLD if bold else FONT_REG, size)

def sentence_for_offset(offset: float) -> str:
    # Edge word-boundary events do not carry sentence IDs. Assign monotonically
    # by rough character progression so caption segments still break at phrases.
    # We later override using punctuation gaps when possible; grouping is not timing-critical.
    joined = ' '.join(SENTENCES)
    return joined

def clean_boundary_text(text: str) -> str:
    text = text.replace('’', "'").strip()
    m = re.search(r"[A-Za-z0-9]+(?:[-–—'][A-Za-z0-9]+)*(?:\.com)?|[A-Za-z0-9.-]+\.com", text)
    return m.group(0) if m else text

async def synthesize_audio() -> tuple[Path, tuple[WordCue, ...]]:
    audio_path = AUDIO_DIR / 'voiceover_word_boundary.mp3'
    cues_path = AUDIO_DIR / 'word_cues.json'
    communicate = edge_tts.Communicate(SCRIPT, VOICE, rate='+0%')
    cues = []
    audio_bytes = bytearray()
    sentence_cursor = 0
    sentence_word_counts = [len(re.findall(r"[A-Za-z0-9]+(?:[-–—'][A-Za-z0-9]+)*(?:\.com)?|[A-Za-z0-9.-]+\.com", s)) for s in SENTENCES]
    word_counter_in_sentence = 0
    for chunk in await communicate.stream().__aiter__():
        if chunk['type'] == 'audio':
            audio_bytes.extend(chunk['data'])
        elif chunk['type'] == 'WordBoundary':
            word = clean_boundary_text(chunk.get('text', ''))
            if not word:
                continue
            start = chunk.get('offset', 0) / 10_000_000
            duration = max(0.08, chunk.get('duration', 0) / 10_000_000)
            sent = SENTENCES[min(sentence_cursor, len(SENTENCES) - 1)]
            cues.append(WordCue(start=start, end=start + duration, word=word, sentence=sent))
            word_counter_in_sentence += 1
            if sentence_cursor < len(sentence_word_counts) and word_counter_in_sentence >= sentence_word_counts[sentence_cursor]:
                sentence_cursor += 1
                word_counter_in_sentence = 0
    audio_path.write_bytes(audio_bytes)
    cues_path.write_text(json.dumps([c.__dict__ for c in cues], indent=2), encoding='utf-8')
    return audio_path, tuple(cues)

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

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

def text(draw, xy, s, size, fill, bold=True, anchor=None, align='left'):
    draw.text(xy, s, font=font(size, bold), fill=fill, anchor=anchor, align=align)

def bg(draw, t):
    # dark blue premium-ish deterministic background, not AI-generated
    draw.rectangle((0, 0, W, H), fill=(10, 14, 24))
    for y in range(0, H, 4):
        c = int(16 + 26 * y / H)
        draw.line((0, y, W, y), fill=(c, c + 5, c + 15))
    # moving soft glow
    cx = 220 + int(60 * math.sin(t * 0.7))
    cy = 300 + int(45 * math.cos(t * 0.45))
    for r, a in [(360, 22), (250, 20), (160, 18)]:
        draw.ellipse((cx-r, cy-r, cx+r, cy+r), fill=(35, 75, 145, a))
    draw.rectangle((0, 0, W, H), outline=(38, 49, 74), width=8)

def scene_for(t):
    if t < 5.4: return 'hook'
    if t < 10.8: return 'same'
    if t < 16.2: return 'domain'
    if t < 23.0: return 'checks'
    if t < 29.2: return 'open_app'
    return 'takeaway'

def draw_invite(draw, t, mode):
    # Phone/browser card
    rounded(draw, (82, 165, 998, 1328), 46, (238, 241, 247), (80, 93, 125), 3)
    rounded(draw, (118, 215, 962, 385), 26, (255, 255, 255), (218, 224, 234), 2)
    text(draw, (152, 252), 'Calendar', 40, (38, 47, 66))
    text(draw, (152, 314), 'From: updates@sample.test', 30, (82, 92, 112), False)

    # meeting card
    rounded(draw, (150, 445, 930, 815), 28, (250, 252, 255), (203, 211, 225), 2)
    text(draw, (196, 497), 'Meeting invite', 52, (38, 48, 66))
    text(draw, (196, 572), 'Project check-in', 36, (69, 79, 99), False)
    urgent = mode in ['same', 'domain']
    rounded(draw, (196, 642, 520, 696), 18, (255, 244, 223) if urgent else (235, 242, 250), (232, 151, 43) if urgent else (128, 140, 160), 2)
    text(draw, (224, 654), 'Starts soon', 28, (205, 117, 24) if urgent else (91, 103, 123))
    for x in [202, 262, 322, 382]:
        draw.ellipse((x, 724, x+42, 766), fill=(211, 218, 230), outline=(178, 188, 207), width=2)

    # button + domain panel
    changed = mode in ['domain', 'checks', 'open_app']
    btn_fill = (43, 94, 185) if not changed else (202, 64, 64)
    rounded(draw, (196, 890, 884, 980), 24, btn_fill, None, 1)
    text(draw, (540, 935), 'SIGN IN TO JOIN', 38, (255, 255, 255), anchor='mm')

    rounded(draw, (150, 1030, 930, 1208), 24, (241, 245, 250), (204, 213, 228), 2)
    text(draw, (196, 1070), 'Link domain', 34, (58, 68, 89))
    domain = 'service.sample.test' if not changed else 'secure-meeting-check.test'
    color = (57, 129, 88) if not changed else (204, 58, 58)
    rounded(draw, (196, 1125, 820, 1178), 16, (235, 249, 241) if not changed else (255, 242, 239), color, 3)
    text(draw, (220, 1138), domain, 29, color)
    if changed:
        text(draw, (196, 1245), 'Domain changed', 42, (207, 54, 54))
    else:
        text(draw, (196, 1245), 'Looks familiar', 42, (60, 120, 90))

def draw_checks(draw, t):
    labels = [('sender', 16.5), ('link domain', 18.2), ('unusual login', 20.0)]
    y = 1218
    for i, (lab, ts) in enumerate(labels):
        active = t >= ts
        col = (247, 202, 76) if active else (122, 133, 155)
        rounded(draw, (155, y + i*87, 925, y + 60 + i*87), 21, (34, 43, 62), col if active else (74, 86, 110), 3)
        text(draw, (205, y + 13 + i*87), f'{i+1}. {lab}', 32, (255, 255, 255) if active else (188, 197, 214))

def draw_open_app(draw, t):
    rounded(draw, (125, 1160, 955, 1395), 34, (28, 38, 57), (93, 112, 148), 3)
    text(draw, (540, 1220), 'Open the service yourself', 44, (255, 255, 255), anchor='mm')
    text(draw, (540, 1292), 'not the rushed button', 42, (247, 202, 76), anchor='mm')
    # app icon + check
    rounded(draw, (464, 1335, 616, 1482), 32, (18, 24, 36), (247, 202, 76), 4)
    draw.line((500, 1411, 532, 1444, 586, 1372), fill=(247, 202, 76), width=10)

def draw_takeaway(draw):
    rounded(draw, (92, 420, 988, 1040), 46, (24, 32, 48), (247, 202, 76), 4)
    text(draw, (540, 570), 'Trust the app', 76, (255, 255, 255), anchor='mm')
    text(draw, (540, 700), 'you opened.', 76, (247, 202, 76), anchor='mm')
    text(draw, (540, 845), 'Not the button that rushed you.', 42, (205, 214, 230), anchor='mm')

def draw_caption(draw, t, word_cues, segments):
    if not word_cues:
        return
    active = active_word_index_at(word_cues, t)
    seg = segment_for_active_word(segments, active)
    active_word = word_cues[active].word
    layout = layout_caption_segment(seg.words, active_word=active_word, config=SAFE)
    f = font(layout.font_size_px, True)
    flat_words = []
    for line in layout.lines:
        flat_words.extend(line)
    for word, box in zip(flat_words, layout.word_boxes):
        x, y, _, _ = box
        fill = (247, 202, 76) if word == active_word else (255, 255, 255)
        # shadow only; no subtitle bar/box
        for dx, dy in [(-2, 2), (2, 2), (0, 3)]:
            draw.text((x+dx, y+dy), word, font=f, fill=(0,0,0,160), stroke_width=2, stroke_fill=(0,0,0,135))
        draw.text((x, y), word, font=f, fill=fill, stroke_width=2, stroke_fill=(6,10,18))

def draw_frame(t, word_cues, segments):
    img = Image.new('RGB', (W, H), (10, 14, 24))
    draw = ImageDraw.Draw(img, 'RGBA')
    bg(draw, t)
    sc = scene_for(t)
    if sc == 'takeaway':
        draw_takeaway(draw)
    else:
        draw_invite(draw, t, 'domain' if sc in ['domain', 'checks', 'open_app'] else sc)
        if sc in ['checks', 'open_app']:
            draw_checks(draw, t)
        if sc == 'open_app':
            draw_open_app(draw, t)
    draw_caption(draw, t, word_cues, segments)
    return img

def render(audio_path: Path, word_cues: tuple[WordCue, ...]):
    audio_duration = ffprobe_duration(audio_path)
    duration = max(audio_duration + 0.5, word_cues[-1].end + 0.6 if word_cues else audio_duration + 0.5)
    segments = build_safe_caption_segments(word_cues, layout=SAFE, min_words=3, target_words=5, max_words=7)
    segment_path = OUT / 'caption_segments_word_aligned.json'
    segment_path.write_text(json.dumps([
        {'start': s.start, 'end': s.end, 'words': s.words, 'text': s.text, 'word_indices': s.word_indices}
        for s in segments
    ], indent=2), encoding='utf-8')

    silent = OUT / f'{CANDIDATE}_preview_v1_word_aligned_silent.mp4'
    final = OUT / f'{CANDIDATE}_preview_v1_word_aligned.mp4'
    cmd = ['ffmpeg','-y','-f','rawvideo','-vcodec','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-an','-c:v','libx264','-pix_fmt','yuv420p','-crf','20',str(silent)]
    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
    frames = int(math.ceil(duration * FPS))
    for i in range(frames):
        t = i / FPS
        proc.stdin.write(draw_frame(t, word_cues, segments).tobytes())
    proc.stdin.close(); proc.wait()
    if proc.returncode:
        raise SystemExit(proc.returncode)
    # Preserve video duration; do not use -shortest.
    subprocess.run(['ffmpeg','-y','-i',str(silent),'-i',str(audio_path),'-c:v','copy','-c:a','aac','-map','0:v:0','-map','1:a:0',str(final)], check=True)

    sample_times = [1,4,8,12,16,19,23,28,min(duration-0.4,32)]
    thumbs=[]
    for ts in sample_times:
        im = draw_frame(ts, word_cues, segments)
        p = QA / f'frame_{str(round(ts,1)).replace(".","_")}s.jpg'
        im.save(p, quality=92)
        thumbs.append((ts, im.resize((270,480))))
    sheet = Image.new('RGB', (810,1440), (12,16,24)); sd=ImageDraw.Draw(sheet)
    for idx,(ts,thumb) in enumerate(thumbs):
        x=(idx%3)*270; y=(idx//3)*480
        sheet.paste(thumb,(x,y)); sd.rectangle((x,y,x+269,y+479),outline=(80,90,115),width=2)
        sd.text((x+12,y+12),f'{ts:.1f}s',font=font(24),fill=(247,202,76))
    contact = OUT / 'calendar-invite-login-trap_contact_sheet_v1.jpg'
    sheet.save(contact, quality=92)

    subtitle_preview = OUT / 'calendar-invite-login-trap_subtitle_style_preview_v1.jpg'
    draw_frame(18.6, word_cues, segments).save(subtitle_preview, quality=94)

    # QA: caption segments should mostly be 3-7 words; no one-word default.
    one_word_segments = [s.text for s in segments if len(s.words) == 1]
    qa = {
        'caption_sync_source': 'edge_tts_word_boundary_offsets',
        'static_phrase_active_word': True,
        'min_words_default': 3,
        'target_words': 5,
        'max_words': 7,
        'one_word_segments': one_word_segments,
        'one_word_segment_count': len(one_word_segments),
        'duration_seconds': duration,
        'audio_duration_seconds': audio_duration,
        'word_cue_count': len(word_cues),
        'segment_count': len(segments),
        'safe_zone': 'bottom caption, no box/bar, tested via contact sheet',
        'summary_fazit_present': True,
        'final_solution': 'Open the service yourself; trust the app you opened, not the rushed button.',
    }
    qa_path = OUT / 'qa_summary_preview_v1.json'
    qa_path.write_text(json.dumps(qa, indent=2), encoding='utf-8')

    manifest = {
        'candidate_id': CANDIDATE,
        'version': VERSION,
        'series': 'Digital Red Flags',
        'title': 'The calendar invite login trap',
        'script': SCRIPT,
        'script_hash': hashlib.sha256(SCRIPT.encode()).hexdigest()[:16],
        'preview_mp4_path': str(final),
        'preview_sha256': sha256(final),
        'silent_mp4_path': str(silent),
        'audio_path': str(audio_path),
        'audio_sha256': sha256(audio_path),
        'word_cues_path': str(AUDIO_DIR / 'word_cues.json'),
        'caption_segments_path': str(segment_path),
        'contact_sheet_path': str(contact),
        'subtitle_preview_path': str(subtitle_preview),
        'qa_summary_path': str(qa_path),
        'duration_seconds': duration,
        'resolution': f'{W}x{H}',
        'fps': FPS,
        'caption_policy': 'static 3-7 word phrase segments with active spoken word highlight from EdgeTTS word boundaries',
        'blocked': {'wan': True, 'i2v': True, 'platform_api': True, 'posting': True, 'scheduling': True},
    }
    manifest_path = OUT / 'manifest_preview_v1_word_aligned.json'
    manifest_path.write_text(json.dumps(manifest, indent=2), encoding='utf-8')
    return manifest

async def main():
    audio_path, cues = await synthesize_audio()
    if len(cues) < 20:
        raise RuntimeError(f'too few word cues: {len(cues)}')
    manifest = render(audio_path, cues)
    # content review package
    review = {
        'candidate_id': CANDIDATE,
        'version': VERSION,
        'production_mode': 'proof_short',
        'selected_hook': 'This calendar invite looks normal. The trap is the login button.',
        'script': SCRIPT,
        'visual_plan': [
            {'time': '0-5s', 'scene': 'normal calendar invite; login button visible', 'purpose': 'visible real problem immediately'},
            {'time': '5-11s', 'scene': 'same meeting/title/guests/urgency', 'purpose': 'recognition and tension'},
            {'time': '11-16s', 'scene': 'link domain changes to fictional suspicious domain', 'purpose': 'mechanism reveal'},
            {'time': '16-23s', 'scene': 'three checks: sender, link domain, unusual login', 'purpose': 'saveable rule'},
            {'time': '23-29s', 'scene': 'button dims; open-service-yourself card', 'purpose': 'real solution'},
            {'time': '29-end', 'scene': 'final takeaway card', 'purpose': 'fazit / memorable rule'},
        ],
        'usefulness_fix': 'Do not sign in from a suspicious invite. Open the service yourself and verify from there.',
        'takeaway': 'Trust the app you opened, not the button that rushed you.',
        'pretty_empty_visual_blocker': {'result': 'pass', 'reason': 'first frame shows a concrete invite/login problem, not abstract mood visuals'},
        'claim_gate': {'result': 'pass', 'risk': 'medium', 'not_claimed': ['this detects all phishing', 'legal/security advice guarantee']},
        'ai_disclosure': 'no realistic AI people/scenes; deterministic mockups and EdgeTTS voice',
        'preview_manifest': manifest,
        'approval_commands': [
            f'APPROVED_PROOF_SHORT {CANDIDATE} {VERSION}',
            f'REVISE_PROOF_SHORT {CANDIDATE} "..."',
            f'REJECT_PROOF_SHORT {CANDIDATE} "Grund"',
        ],
    }
    (BASE / 'review_package_v1_word_aligned.json').write_text(json.dumps(review, indent=2), encoding='utf-8')
    print(json.dumps({'ok': True, 'manifest': manifest, 'review_package': str(BASE / 'review_package_v1_word_aligned.json')}, indent=2))

if __name__ == '__main__':
    asyncio.run(main())


todo

Letztes Feedback in Anforderungen für nächstes Video übersetzen

Vorhandene AutoShorts/Proof-Short Artefakte und Pipeline finden

Nächstes Video als ReviewPackage mit Hook, Script, Visualplan, Fazit und Caption-Sync-Regel ausarbeiten


ReviewPackage/Entwurf knapp an User liefern und nächste Freigabe-Kommandos nennen


terminal

Output:
Traceback (most recent call last):
  File "/tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py", line 371, in <module>
    asyncio.run(main())
  File "/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py", line 336, in main
    audio_path, cues = await synthesize_audio()
                       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py", line 88, in synthesize_audio
    for chunk in await communicate.stream().__aiter__():
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: object async_generator can't be used in 'await' expression


patch

    for chunk in await communicate.stream().__aiter__():
        if chunk['type'] == 'audio':

    async for chunk in communicate.stream():
        if chunk['type'] == 'audio':


--- a//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
+++ b//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
@@ -85,7 +85,7 @@
     sentence_cursor = 0
     sentence_word_counts = [len(re.findall(r"[A-Za-z0-9]+(?:[-–—'][A-Za-z0-9]+)*(?:\.com)?|[A-Za-z0-9.-]+\.com", s)) for s in SENTENCES]
     word_counter_in_sentence = 0
-    for chunk in await communicate.stream().__aiter__():
+    async for chunk in communicate.stream():
         if chunk['type'] == 'audio':
             audio_bytes.extend(chunk['data'])
         elif chunk['type'] == 'WordBoundary':


terminal

{"output": "Traceback (most recent call last):\n  File \"/tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py\", line 371, in <module>\n    asyncio.run(main())\n  File \"/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/runners.py\", line 190, in run\n    return runner.run(main)\n           ^^^^^^^^^^^^^^^^\n  File \"/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/runners.py\", line 118, in run\n    return self._loop.run_until_complete(task)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/home/agent/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/asyncio/base_events.py\", line 654, in run_until_complete\n    return future.result()\n           ^^^^^^^^^^^^^^^\n  File \"/tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py\", line 338, in main\n    raise RuntimeError(f'too few word cues: {len(cues)}')\nRuntimeError: too few word cues: 0", "exit_code": 1, "error": null}

[Tool loop warning: repeated_exact_failure_warning; count=2; terminal has failed 2 times with identical arguments. This looks like a loop; inspect the error and change strategy instead of retrying it unchanged.]


python - <<'PY'
import asyncio, edge_tts
async def main():
    c=edge_tts.Communicate('This calendar invite looks normal. The trap is the login button.', 'en-GB-RyanNeural')
    types={}
    async for chunk in c.stream():
        types[chunk.get('type')]=types.get(chunk.get('type'),0)+1
        if chunk.get('type')!='audio': print(chunk)
    print(types)
asyncio.run(main())
PY


Output:
{'type': 'SentenceBoundary', 'offset': 1000000, 'duration': 26750000, 'text': 'This calendar invite looks normal.'}
{'type': 'SentenceBoundary', 'offset': 27250000, 'duration': 24625000, 'text': 'The trap is the login button.'}
{'audio': 44, 'SentenceBoundary': 2}


python - <<'PY'
import inspect, edge_tts
print(inspect.signature(edge_tts.Communicate))
PY


Output: (text: str, voice: str = 'en-US-EmmaMultilingualNeural', *, rate: str = '+0%', volume: str = '+0%', pitch: str = '+0Hz', boundary: Literal['WordBoundary', 'SentenceBoundary'] = 'SentenceBoundary', connector: Optional[aiohttp.connector.BaseConnector] = None, proxy: Optional[str] = None, connect_timeout: Optional[int] = 10, receive_timeout: Optional[int] = 60)


patch

communicate = edge_tts.Communicate(SCRIPT, VOICE, rate='+0%')

communicate = edge_tts.Communicate(SCRIPT, VOICE, rate='+0%', boundary='WordBoundary')


--- a//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
+++ b//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
@@ -79,7 +79,7 @@
 async def synthesize_audio() -> tuple[Path, tuple[WordCue, ...]]:
     audio_path = AUDIO_DIR / 'voiceover_word_boundary.mp3'
     cues_path = AUDIO_DIR / 'word_cues.json'
-    communicate = edge_tts.Communicate(SCRIPT, VOICE, rate='+0%')
+    communicate = edge_tts.Communicate(SCRIPT, VOICE, rate='+0%', boundary='WordBoundary')
     cues = []
     audio_bytes = bytearray()
     sentence_cursor = 0


terminal

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, rawvideo, from 'fd:':
  Duration: N/A, start: 0.000000, bitrate: 1194393 kb/s
  Stream #0:0: Video: rawvideo (RGB[24] / 0x18424752), rgb24, 1080x1920, 1194393 kb/s, 24 tbr, 24 tbn
Stream mapping:
  Stream #0:0 -> #0:0 (rawvideo (native) -> h264 (libx264))
[libx264 @ 0x5695b5a9f3c0] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512
[libx264 @ 0x5695b5a9f3c0] profile High, level 4.0, 4:2:0, 8-bit
[libx264 @ 0x5695b5a9f3c0] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=9 lookahead_threads=1 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=2 keyint=250 keyint_min=24 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=20.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
Output #0, mp4, to '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4':
  Metadata:
    encoder         : Lavf60.16.100
  Stream #0:0: Video: h264 (avc1 / 0x31637661), yuv420p(tv, progressive), 1080x1920, q=2-31, 24 fps, 12288 tbn
    Metadata:
      encoder         : Lavc60.31.102 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    3 fps=0.9 q=25.0 size=       0kB time=00:00:00.04 bitrate=   9.2kbits/s speed=0.0132x
frame=   12 fps=3.3 q=25.0 size=       0kB time=00:00:00.41 bitrate=   0.9kbits/s speed=0.113x
frame=   21 fps=5.0 q=25.0 size=       0kB time=00:00:00.79 bitrate=   0.5kbits/s speed=0.189x
frame=   31 fps=6.6 q=25.0 size=       0kB time=00:00:01.20 bitrate=   0.3kbits/s speed=0.256x
frame=   41 fps=7.8 q=25.0 size=       0kB time=00:00:01.62 bitrate=   0.2kbits/s speed=0.31x
frame=   50 fps=8.7 q=25.0 size=       0kB time=00:00:02.00 bitrate=   0.2kbits/s speed=0.348x
frame=   60 fps=9.5 q=25.0 size=       0kB time=00:00:02.41 bitrate=   0.2kbits/s speed=0.384x
frame=   70 fps= 10 q=25.0 size=       0kB time=00:00:02.83 bitrate=   0.1kbits/s speed=0.413x
frame=   81 fps= 11 q=25.0 size=       0kB time=00:00:03.29 bitrate=   0.1kbits/s speed=0.444x
frame=   92 fps= 12 q=25.0 size=       0kB time=00:00:03.70 bitrate=   0.1kbits/s speed=0.468x
frame=  102 fps= 12 q=25.0 size=       0kB time=00:00:04.16 bitrate=   0.1kbits/s speed=0.492x
frame=  113 fps= 13 q=25.0 size=       0kB time=00:00:04.62 bitrate=   0.1kbits/s speed=0.515x
frame=  124 fps= 13 q=25.0 size=       0kB time=00:00:05.08 bitrate=   0.1kbits/s speed=0.535x
frame=  135 fps= 13 q=25.0 size=       0kB time=00:00:05.54 bitrate=   0.1kbits/s speed=0.552x
frame=  146 fps= 14 q=25.0 size=       0kB time=00:00:06.00 bitrate=   0.1kbits/s speed=0.569x
frame=  157 fps= 14 q=25.0 size=       0kB time=00:00:06.45 bitrate=   0.1kbits/s speed=0.584x
frame=  169 fps= 15 q=25.0 size=       0kB time=00:00:06.91 bitrate=   0.1kbits/s speed=0.598x
frame=  180 fps= 15 q=25.0 size=       0kB time=00:00:07.37 bitrate=   0.1kbits/s speed=0.611x
frame=  190 fps= 15 q=25.0 size=       0kB time=00:00:07.83 bitrate=   0.0kbits/s speed=0.622x
frame=  201 fps= 15 q=25.0 size=       0kB time=00:00:08.29 bitrate=   0.0kbits/s speed=0.633x
frame=  212 fps= 16 q=25.0 size=       0kB time=00:00:08.70 bitrate=   0.0kbits/s speed=0.639x
frame=  221 fps= 16 q=25.0 size=       0kB time=00:00:09.12 bitrate=   0.0kbits/s speed=0.645x
frame=  230 fps= 16 q=25.0 size=       0kB time=00:00:09.50 bitrate=   0.0kbits/s speed=0.648x
frame=  239 fps= 16 q=25.0 size=       0kB time=00:00:09.87 bitrate=   0.0kbits/s speed=0.649x
frame=  248 fps= 16 q=25.0 size=       0kB time=00:00:10.25 bitrate=   0.0kbits/s speed=0.65x
frame=  257 fps= 16 q=25.0 size=     256kB time=00:00:10.62 bitrate= 197.4kbits/s speed=0.651x
frame=  267 fps= 16 q=25.0 size=     256kB time=00:00:11.00 bitrate= 190.7kbits/s speed=0.652x
frame=  276 fps= 16 q=25.0 size=     256kB time=00:00:11.37 bitrate= 184.4kbits/s speed=0.653x
frame=  285 fps= 16 q=25.0 size=     256kB time=00:00:11.75 bitrate= 178.5kbits/s speed=0.654x
frame=  294 fps= 16 q=25.0 size=     256kB time=00:00:12.12 bitrate= 173.0kbits/s speed=0.657x
frame=  304 fps= 16 q=25.0 size=     256kB time=00:00:12.54 bitrate= 167.2kbits/s speed=0.66x
frame=  314 fps= 16 q=25.0 size=     256kB time=00:00:12.95 bitrate= 161.9kbits/s speed=0.664x
frame=  322 fps= 16 q=25.0 size=     256kB time=00:00:13.33 bitrate= 157.3kbits/s speed=0.665x
frame=  332 fps= 16 q=25.0 size=     256kB time=00:00:13.70 bitrate= 153.0kbits/s speed=0.667x
frame=  341 fps= 16 q=25.0 size=     256kB time=00:00:14.08 bitrate= 148.9kbits/s speed=0.668x
frame=  350 fps= 16 q=25.0 size=     256kB time=00:00:14.45 bitrate= 145.1kbits/s speed=0.669x
frame=  359 fps= 16 q=25.0 size=     256kB time=00:00:14.83 bitrate= 141.4kbits/s speed=0.67x
frame=  368 fps= 16 q=25.0 size=     256kB time=00:00:15.20 bitrate= 137.9kbits/s speed=0.671x
frame=  377 fps= 16 q=25.0 size=     256kB time=00:00:15.58 bitrate= 134.6kbits/s speed=0.672x
frame=  386 fps= 16 q=25.0 size=     256kB time=00:00:15.95 bitrate= 131.4kbits/s speed=0.673x
frame=  395 fps= 16 q=25.0 size=     256kB time=00:00:16.33 bitrate= 128.4kbits/s speed=0.674x
frame=  403 fps= 16 q=25.0 size=     256kB time=00:00:16.70 bitrate= 125.5kbits/s speed=0.674x
frame=  411 fps= 16 q=25.0 size=     256kB time=00:00:17.04 bitrate= 123.1kbits/s speed=0.673x
frame=  420 fps= 16 q=25.0 size=     256kB time=00:00:17.41 bitrate= 120.4kbits/s speed=0.673x
frame=  429 fps= 16 q=25.0 size=     256kB time=00:00:17.79 bitrate= 117.9kbits/s speed=0.673x
frame=  437 fps= 16 q=25.0 size=     256kB time=00:00:18.12 bitrate= 115.7kbits/s speed=0.673x
frame=  445 fps= 16 q=25.0 size=     256kB time=00:00:18.45 bitrate= 113.6kbits/s speed=0.673x
frame=  454 fps= 16 q=25.0 size=     256kB time=00:00:18.83 bitrate= 111.4kbits/s speed=0.673x
frame=  464 fps= 16 q=25.0 size=     256kB time=00:00:19.25 bitrate= 109.0kbits/s speed=0.675x
frame=  474 fps= 16 q=25.0 size=     256kB time=00:00:19.66 bitrate= 106.7kbits/s speed=0.677x
frame=  484 fps= 16 q=25.0 size=     256kB time=00:00:20.08 bitrate= 104.4kbits/s speed=0.679x
frame=  493 fps= 16 q=25.0 size=     256kB time=00:00:20.45 bitrate= 102.5kbits/s speed=0.68x
frame=  502 fps= 16 q=25.0 size=     512kB time=00:00:20.83 bitrate= 201.3kbits/s speed=0.679x
frame=  510 fps= 16 q=25.0 size=     512kB time=00:00:21.16 bitrate= 198.2kbits/s speed=0.679x
frame=  518 fps= 16 q=25.0 size=     512kB time=00:00:21.50 bitrate= 195.1kbits/s speed=0.679x
frame=  527 fps= 16 q=25.0 size=     512kB time=00:00:21.87 bitrate= 191.8kbits/s speed=0.679x
frame=  535 fps= 16 q=25.0 size=     512kB time=00:00:22.20 bitrate= 188.9kbits/s speed=0.678x
frame=  544 fps= 16 q=25.0 size=     512kB time=00:00:22.58 bitrate= 185.7kbits/s speed=0.678x
frame=  553 fps= 16 q=25.0 size=     512kB time=00:00:22.95 bitrate= 182.7kbits/s speed=0.679x
frame=  562 fps= 16 q=25.0 size=     512kB time=00:00:23.33 bitrate= 179.8kbits/s speed=0.68x
frame=  571 fps= 16 q=25.0 size=     512kB time=00:00:23.70 bitrate= 176.9kbits/s speed=0.68x
frame=  580 fps= 16 q=25.0 size=     512kB time=00:00:24.08 bitrate= 174.2kbits/s speed=0.681x
frame=  589 fps= 16 q=25.0 size=     512kB time=00:00:24.45 bitrate= 171.5kbits/s speed=0.682x
frame=  600 fps= 16 q=25.0 size=     512kB time=00:00:24.87 bitrate= 168.6kbits/s speed=0.683x
frame=  608 fps= 16 q=25.0 size=     512kB time=00:00:25.25 bitrate= 166.1kbits/s speed=0.684x
frame=  619 fps= 17 q=25.0 size=     512kB time=00:00:25.66 bitrate= 163.4kbits/s speed=0.685x
frame=  629 fps= 17 q=25.0 size=     512kB time=00:00:26.08 bitrate= 160.8kbits/s speed=0.686x
frame=  639 fps= 17 q=25.0 size=     512kB time=00:00:26.50 bitrate= 158.3kbits/s speed=0.687x
frame=  649 fps= 17 q=25.0 size=     512kB time=00:00:26.91 bitrate= 155.8kbits/s speed=0.688x
frame=  661 fps= 17 q=25.0 size=     512kB time=00:00:27.41 bitrate= 153.0kbits/s speed=0.691x
frame=  673 fps= 17 q=25.0 size=     512kB time=00:00:27.91 bitrate= 150.3kbits/s speed=0.695x
frame=  685 fps= 17 q=25.0 size=     512kB time=00:00:28.41 bitrate= 147.6kbits/s speed=0.698x
frame=  696 fps= 17 q=25.0 size=     512kB time=00:00:28.87 bitrate= 145.3kbits/s speed= 0.7x
frame=  707 fps= 17 q=25.0 size=     768kB time=00:00:29.33 bitrate= 214.5kbits/s speed=0.703x
frame=  717 fps= 17 q=25.0 size=     768kB time=00:00:29.79 bitrate= 211.2kbits/s speed=0.705x
frame=  728 fps= 17 q=25.0 size=     768kB time=00:00:30.25 bitrate= 208.0kbits/s speed=0.707x
[out#0/mp4 @ 0x5695b5a9dd00] video:888kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 1.128151%
frame=  784 fps= 18 q=-1.0 Lsize=     898kB time=00:00:32.54 bitrate= 226.0kbits/s speed=0.753x
[libx264 @ 0x5695b5a9f3c0] frame I:4     Avg QP:13.92  size: 55908
[libx264 @ 0x5695b5a9f3c0] frame P:198   Avg QP:17.87  size:  2717
[libx264 @ 0x5695b5a9f3c0] frame B:582   Avg QP:22.78  size:   252
[libx264 @ 0x5695b5a9f3c0] consecutive B-frames:  1.0%  0.0%  0.0% 99.0%
[libx264 @ 0x5695b5a9f3c0] mb I  I16..4: 67.1% 17.5% 15.4%
[libx264 @ 0x5695b5a9f3c0] mb P  I16..4:  0.6%  0.1%  0.3%  P16..4:  4.1%  0.2%  0.1%  0.0%  0.0%    skip:94.6%
[libx264 @ 0x5695b5a9f3c0] mb B  I16..4:  0.0%  0.0%  0.0%  B16..8:  2.5%  0.0%  0.0%  direct: 0.0%  skip:97.5%  L0:54.6% L1:45.4% BI: 0.1%
[libx264 @ 0x5695b5a9f3c0] 8x8 transform intra:18.2% inter:39.4%
[libx264 @ 0x5695b5a9f3c0] coded y,uvDC,uvAC intra: 14.0% 15.3% 11.3% inter: 0.1% 0.1% 0.1%
[libx264 @ 0x5695b5a9f3c0] i16 v,h,dc,p: 24% 76%  0%  0%
[libx264 @ 0x5695b5a9f3c0] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 49% 10% 39%  2%  0%  0%  0%  0%  0%
[libx264 @ 0x5695b5a9f3c0] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 34% 42%  8%  2%  2%  3%  3%  2%  3%
[libx264 @ 0x5695b5a9f3c0] i8c dc,h,v,p: 70% 20% 10%  1%
[libx264 @ 0x5695b5a9f3c0] Weighted P-Frames: Y:0.0% UV:0.0%
[libx264 @ 0x5695b5a9f3c0] ref P L0: 85.9%  1.4%  9.4%  3.4%
[libx264 @ 0x5695b5a9f3c0] ref B L0: 61.0% 37.9%  1.1%
[libx264 @ 0x5695b5a9f3c0] ref B L1: 97.3%  2.7%
[libx264 @ 0x5695b5a9f3c0] kb/s:222.49
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_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Duration: 00:00:32.67, start: 0.000000, bitrate: 225 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, 222 kb/s, 24 fps, 24 tbr, 12288 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
[mp3 @ 0x59fd419ac0c0] Estimating duration from bitrate, this may be inaccurate
Input #1, mp3, from '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/voiceover_word_boundary.mp3':
  Duration: 00:00:32.14, 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 (copy)
  Stream #1:0 -> #0:1 (mp3 (mp3float) -> aac (native))
Press [q] to stop, [?] for help
Output #0, mp4, to '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, q=2-31, 222 kb/s, 24 fps, 24 tbr, 12288 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
  Stream #0:1: Audio: aac (LC) (mp4a / 0x6134706D), 24000 Hz, mono, fltp, 69 kb/s
    Metadata:
      encoder         : Lavc60.31.102 aac
size=       0kB time=N/A bitrate=N/A speed=N/A
[out#0/mp4 @ 0x59fd41a22b40] video:888kB audio:256kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 1.737114%
size=    1164kB time=00:00:32.54 bitrate= 293.0kbits/s speed=68.5x
[aac @ 0x59fd41a23580] Qavg: 17120.062
{
  "ok": true,
  "manifest": {
    "candidate_id": "calendar-invite-login-trap-30s",
    "version": "v1.0.0-word-aligned-proof",
    "series": "Digital Red Flags",
    "title": "The calendar invite login trap",
    "script": "This calendar invite looks normal. The trap is the login button. Same meeting title. Same guest list. Same urgent tone. But the link points somewhere else. Before you sign in, check three things: the sender, the link domain, and whether the login asks for more than usual. If the domain feels off, do not sign in from the invite. Open the service yourself. The fix is simple: trust the app you opened, not the button that rushed you.",
    "script_hash": "937a8fe3c8881ae2",
    "preview_mp4_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned.mp4",
    "preview_sha256": "d325d99bab7df4d8488794224ba593a6252a463e0d1b7c5f276cade2e0fc1011",
    "silent_mp4_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4",
    "audio_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/voiceover_word_boundary.mp3",
    "audio_sha256": "ebf6088d996bcf1b1edb22d3fc5cf9eb1ee03d155eb4bdf45d1e0de80365ce4d",
    "word_cues_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/word_cues.json",
    "caption_segments_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/caption_segments_word_aligned.json",
    "contact_sheet_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap_contact_sheet_v1.jpg",
    "subtitle_preview_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap_subtitle_style_preview_v1.jpg",
    "qa_summary_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/qa_summary_preview_v1.json",
    "duration_seconds": 32.636,
    "resolution": "1080x1920",
    "fps": 24,
    "caption_policy": "static 3-7 word phrase segments with active spoken word highlight from EdgeTTS word boundaries",
    "blocked": {
      "wan": true,
      "i2v": true,
      "platform_api": true,
      "posting": true,
      "scheduling": true
    }
  },
  "review_package": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/review_package_v1_word_aligned.json"
}


Explanation:
**Inspecting with QA tools**

I need to conduct a quality assurance inspection using visual analysis. I think I might utilize a contact sheet or subtitles for that. Using the vision analysis tool seems like a good idea, and maybe I could incorporate ffprobe as well. Overall, I’ll ensure to read through the QA materials to support the inspection process. It's all about getting the details right to maintain quality!

Question: Prüfe dieses 9:16 Shorts-Contact-Sheet: Sind die Captions innerhalb der Safe Zone, lesbar, nicht abgeschnitten? Ist der visuelle Ablauf verständlich und gibt es problematische echte Daten/Logos/QR-Codes?


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

Question: Prüfe dieses 9:16 Shorts-Contact-Sheet: Sind die Captions innerhalb der Safe Zone, lesbar, nicht abgeschnitten? Ist der visuelle Ablauf verständlich und gibt es problematische echte Daten/Logos/QR-Codes?


     1|{
     2|  "caption_sync_source": "edge_tts_word_boundary_offsets",
     3|  "static_phrase_active_word": true,
     4|  "min_words_default": 3,
     5|  "target_words": 5,
     6|  "max_words": 7,
     7|  "one_word_segments": [],
     8|  "one_word_segment_count": 0,
     9|  "duration_seconds": 32.636,
    10|  "audio_duration_seconds": 32.136,
    11|  "word_cue_count": 78,
    12|  "segment_count": 16,
    13|  "safe_zone": "bottom caption, no box/bar, tested via contact sheet",
    14|  "summary_fazit_present": true,
    15|  "final_solution": "Open the service yourself; trust the app you opened, not the rushed button."
    16|}


Output:
{
    "programs": [

    ],
    "streams": [
        {
            "codec_type": "video",
            "width": 1080,
            "height": 1920,
            "r_frame_rate": "24/1"
        },
        {
            "codec_type": "audio",
            "r_frame_rate": "0/0"
        }
    ],
    "format": {
        "duration": "32.666667",
        "size": "1191775"
    }
}
d325d99bab7df4d8488794224ba593a6252a463e0d1b7c5f276cade2e0fc1011  /tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned.mp4


Explanation:
**Revising check phrases**

I’m thinking about the active phrases for the user’s request. I see “check three things” works well, but “the sender” has only two words, which might not fit. The user prefers between 3 to 7 words, so I should aim for a minimum of 3. I could create a Phrases array to generate counts, like "before you sign in," "check three things," and more. Maybe also try reformulating “first the sender” to simplify the script?

patch

SCRIPT = (
    "This calendar invite looks normal. The trap is the login button. "
    "Same meeting title. Same guest list. Same urgent tone. "
    "But the link points somewhere else. Before you sign in, check three things: "
    "the sender, the link domain, and whether the login asks for more than usual. "
    "If the domain feels off, do not sign in from the invite. Open the service yourself. "
    "The fix is simple: trust the app you opened, not the button that rushed you."
)

SCRIPT = (
    "This calendar invite looks normal. The trap is the login button. "
    "Same meeting title. Same guest list. Same urgent tone. "
    "But the link points somewhere else. Before you sign in, slow down. "
    "First, check who sent it. Then check the link domain. Then notice any unusual login request. "
    "If the domain feels off, do not sign in from the invite. Open the service yourself. "
    "The fix is simple: trust the app you opened, not the button that rushed you."
)


--- a//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
+++ b//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
@@ -42,8 +42,8 @@
 SCRIPT = (
     "This calendar invite looks normal. The trap is the login button. "
     "Same meeting title. Same guest list. Same urgent tone. "
-    "But the link points somewhere else. Before you sign in, check three things: "
-    "the sender, the link domain, and whether the login asks for more than usual. "
+    "But the link points somewhere else. Before you sign in, slow down. "
+    "First, check who sent it. Then check the link domain. Then notice any unusual login request. "
     "If the domain feels off, do not sign in from the invite. Open the service yourself. "
     "The fix is simple: trust the app you opened, not the button that rushed you."
 )


labels = [('sender', 16.5), ('link domain', 18.2), ('unusual login request', 20.0)]

labels = [('sender', 16.5), ('link domain', 18.2), ('unusual login', 20.0)]


--- a//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
+++ b//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
@@ -179,7 +179,7 @@

 def draw_checks(draw, t):
-    labels = [('sender', 16.5), ('link domain', 18.2), ('unusual login', 20.0)]
+    labels = [('sender', 16.5), ('link domain', 18.2), ('unusual login request', 20.0)]
     y = 1218
     for i, (lab, ts) in enumerate(labels):
         active = t >= ts


terminal

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, rawvideo, from 'fd:':
  Duration: N/A, start: 0.000000, bitrate: 1194393 kb/s
  Stream #0:0: Video: rawvideo (RGB[24] / 0x18424752), rgb24, 1080x1920, 1194393 kb/s, 24 tbr, 24 tbn
Stream mapping:
  Stream #0:0 -> #0:0 (rawvideo (native) -> h264 (libx264))
[libx264 @ 0x64a5c11903c0] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512
[libx264 @ 0x64a5c11903c0] profile High, level 4.0, 4:2:0, 8-bit
[libx264 @ 0x64a5c11903c0] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=9 lookahead_threads=1 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=2 keyint=250 keyint_min=24 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=20.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
Output #0, mp4, to '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4':
  Metadata:
    encoder         : Lavf60.16.100
  Stream #0:0: Video: h264 (avc1 / 0x31637661), yuv420p(tv, progressive), 1080x1920, q=2-31, 24 fps, 12288 tbn
    Metadata:
      encoder         : Lavc60.31.102 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    0 fps=0.0 q=0.0 size=       0kB time=N/A bitrate=N/A speed=N/A
frame=    1 fps=0.3 q=25.0 size=       0kB time=-00:00:00.04 bitrate=N/A speed=N/A
frame=   11 fps=3.0 q=25.0 size=       0kB time=00:00:00.37 bitrate=   1.0kbits/s speed=0.101x
frame=   20 fps=4.7 q=25.0 size=       0kB time=00:00:00.75 bitrate=   0.5kbits/s speed=0.178x
frame=   30 fps=6.3 q=25.0 size=       0kB time=00:00:01.16 bitrate=   0.3kbits/s speed=0.245x
frame=   39 fps=7.4 q=25.0 size=       0kB time=00:00:01.54 bitrate=   0.2kbits/s speed=0.292x
frame=   48 fps=8.3 q=25.0 size=       0kB time=00:00:01.91 bitrate=   0.2kbits/s speed=0.331x
frame=   57 fps=9.1 q=25.0 size=       0kB time=00:00:02.29 bitrate=   0.2kbits/s speed=0.364x
frame=   67 fps=9.8 q=25.0 size=       0kB time=00:00:02.70 bitrate=   0.1kbits/s speed=0.397x
frame=   78 fps= 11 q=25.0 size=       0kB time=00:00:03.16 bitrate=   0.1kbits/s speed=0.429x
frame=   88 fps= 11 q=25.0 size=       0kB time=00:00:03.58 bitrate=   0.1kbits/s speed=0.455x
frame=  100 fps= 12 q=25.0 size=       0kB time=00:00:04.04 bitrate=   0.1kbits/s speed=0.48x
frame=  109 fps= 12 q=25.0 size=       0kB time=00:00:04.45 bitrate=   0.1kbits/s speed= 0.5x
frame=  120 fps= 13 q=25.0 size=       0kB time=00:00:04.91 bitrate=   0.1kbits/s speed=0.52x
frame=  131 fps= 13 q=25.0 size=       0kB time=00:00:05.37 bitrate=   0.1kbits/s speed=0.538x
frame=  142 fps= 13 q=25.0 size=       0kB time=00:00:05.83 bitrate=   0.1kbits/s speed=0.554x
frame=  153 fps= 14 q=25.0 size=       0kB time=00:00:06.29 bitrate=   0.1kbits/s speed=0.569x
frame=  164 fps= 14 q=25.0 size=       0kB time=00:00:06.75 bitrate=   0.1kbits/s speed=0.584x
frame=  175 fps= 14 q=25.0 size=       0kB time=00:00:07.20 bitrate=   0.1kbits/s speed=0.597x
frame=  186 fps= 15 q=25.0 size=       0kB time=00:00:07.66 bitrate=   0.1kbits/s speed=0.609x
frame=  197 fps= 15 q=25.0 size=       0kB time=00:00:08.12 bitrate=   0.0kbits/s speed=0.62x
frame=  208 fps= 15 q=25.0 size=       0kB time=00:00:08.58 bitrate=   0.0kbits/s speed=0.631x
frame=  219 fps= 15 q=25.0 size=       0kB time=00:00:09.04 bitrate=   0.0kbits/s speed=0.639x
frame=  228 fps= 16 q=25.0 size=       0kB time=00:00:09.41 bitrate=   0.0kbits/s speed=0.641x
frame=  237 fps= 16 q=25.0 size=       0kB time=00:00:09.79 bitrate=   0.0kbits/s speed=0.643x
frame=  245 fps= 16 q=25.0 size=       0kB time=00:00:10.12 bitrate=   0.0kbits/s speed=0.644x
frame=  254 fps= 16 q=25.0 size=     256kB time=00:00:10.50 bitrate= 199.8kbits/s speed=0.645x
frame=  263 fps= 16 q=25.0 size=     256kB time=00:00:10.87 bitrate= 192.9kbits/s speed=0.647x
frame=  272 fps= 16 q=25.0 size=     256kB time=00:00:11.25 bitrate= 186.4kbits/s speed=0.649x
frame=  281 fps= 16 q=25.0 size=     256kB time=00:00:11.62 bitrate= 180.4kbits/s speed=0.651x
frame=  290 fps= 16 q=25.0 size=     256kB time=00:00:12.00 bitrate= 174.8kbits/s speed=0.654x
frame=  299 fps= 16 q=25.0 size=     256kB time=00:00:12.37 bitrate= 169.5kbits/s speed=0.656x
frame=  308 fps= 16 q=25.0 size=     256kB time=00:00:12.75 bitrate= 164.5kbits/s speed=0.658x
frame=  317 fps= 16 q=25.0 size=     256kB time=00:00:13.12 bitrate= 159.8kbits/s speed=0.66x
frame=  326 fps= 16 q=25.0 size=     256kB time=00:00:13.50 bitrate= 155.4kbits/s speed=0.662x
frame=  335 fps= 16 q=25.0 size=     256kB time=00:00:13.87 bitrate= 151.2kbits/s speed=0.663x
frame=  345 fps= 16 q=25.0 size=     256kB time=00:00:14.25 bitrate= 147.2kbits/s speed=0.663x
frame=  354 fps= 16 q=25.0 size=     256kB time=00:00:14.62 bitrate= 143.4kbits/s speed=0.663x
frame=  363 fps= 16 q=25.0 size=     256kB time=00:00:15.04 bitrate= 139.4kbits/s speed=0.665x
frame=  373 fps= 16 q=25.0 size=     256kB time=00:00:15.45 bitrate= 135.7kbits/s speed=0.667x
frame=  383 fps= 16 q=25.0 size=     256kB time=00:00:15.87 bitrate= 132.1kbits/s speed=0.67x
frame=  393 fps= 16 q=25.0 size=     256kB time=00:00:16.29 bitrate= 128.7kbits/s speed=0.672x
frame=  402 fps= 16 q=25.0 size=     256kB time=00:00:16.66 bitrate= 125.9kbits/s speed=0.673x
frame=  412 fps= 16 q=25.0 size=     256kB time=00:00:17.08 bitrate= 122.8kbits/s speed=0.675x
frame=  421 fps= 16 q=25.0 size=     256kB time=00:00:17.45 bitrate= 120.1kbits/s speed=0.675x
frame=  430 fps= 16 q=25.0 size=     256kB time=00:00:17.83 bitrate= 117.6kbits/s speed=0.677x
frame=  439 fps= 16 q=25.0 size=     256kB time=00:00:18.20 bitrate= 115.2kbits/s speed=0.678x
frame=  449 fps= 16 q=25.0 size=     256kB time=00:00:18.62 bitrate= 112.6kbits/s speed=0.68x
frame=  458 fps= 16 q=25.0 size=     256kB time=00:00:19.00 bitrate= 110.4kbits/s speed=0.681x
frame=  467 fps= 16 q=25.0 size=     256kB time=00:00:19.37 bitrate= 108.3kbits/s speed=0.681x
frame=  476 fps= 16 q=25.0 size=     256kB time=00:00:19.75 bitrate= 106.2kbits/s speed=0.681x
frame=  484 fps= 16 q=25.0 size=     256kB time=00:00:20.08 bitrate= 104.4kbits/s speed=0.68x
frame=  492 fps= 16 q=22.0 size=     256kB time=00:00:20.41 bitrate= 102.7kbits/s speed=0.68x
frame=  501 fps= 16 q=25.0 size=     256kB time=00:00:20.79 bitrate= 100.9kbits/s speed=0.68x
frame=  510 fps= 16 q=25.0 size=     512kB time=00:00:21.12 bitrate= 198.6kbits/s speed=0.679x
frame=  518 fps= 16 q=25.0 size=     512kB time=00:00:21.45 bitrate= 195.5kbits/s speed=0.678x
frame=  525 fps= 16 q=25.0 size=     512kB time=00:00:21.79 bitrate= 192.5kbits/s speed=0.678x
frame=  533 fps= 16 q=25.0 size=     512kB time=00:00:22.12 bitrate= 189.6kbits/s speed=0.677x
frame=  542 fps= 16 q=25.0 size=     512kB time=00:00:22.50 bitrate= 186.4kbits/s speed=0.677x
frame=  551 fps= 16 q=25.0 size=     512kB time=00:00:22.87 bitrate= 183.4kbits/s speed=0.677x
frame=  561 fps= 16 q=25.0 size=     512kB time=00:00:23.29 bitrate= 180.1kbits/s speed=0.678x
frame=  571 fps= 16 q=25.0 size=     512kB time=00:00:23.66 bitrate= 177.2kbits/s speed=0.679x
frame=  579 fps= 16 q=25.0 size=     512kB time=00:00:24.04 bitrate= 174.5kbits/s speed=0.68x
frame=  588 fps= 16 q=25.0 size=     512kB time=00:00:24.41 bitrate= 171.8kbits/s speed=0.68x
frame=  596 fps= 16 q=25.0 size=     512kB time=00:00:24.75 bitrate= 169.5kbits/s speed=0.68x
frame=  604 fps= 16 q=25.0 size=     512kB time=00:00:25.08 bitrate= 167.2kbits/s speed=0.679x
frame=  612 fps= 16 q=25.0 size=     512kB time=00:00:25.41 bitrate= 165.0kbits/s speed=0.679x
frame=  620 fps= 16 q=25.0 size=     512kB time=00:00:25.75 bitrate= 162.9kbits/s speed=0.679x
frame=  628 fps= 16 q=25.0 size=     512kB time=00:00:26.08 bitrate= 160.8kbits/s speed=0.678x
frame=  636 fps= 16 q=25.0 size=     512kB time=00:00:26.41 bitrate= 158.8kbits/s speed=0.677x
frame=  644 fps= 16 q=25.0 size=     512kB time=00:00:26.75 bitrate= 156.8kbits/s speed=0.676x
frame=  654 fps= 16 q=25.0 size=     512kB time=00:00:27.16 bitrate= 154.4kbits/s speed=0.678x
frame=  665 fps= 16 q=25.0 size=     512kB time=00:00:27.62 bitrate= 151.8kbits/s speed=0.681x
frame=  676 fps= 16 q=25.0 size=     512kB time=00:00:28.08 bitrate= 149.4kbits/s speed=0.683x
frame=  687 fps= 17 q=25.0 size=     512kB time=00:00:28.54 bitrate= 147.0kbits/s speed=0.686x
frame=  698 fps= 17 q=25.0 size=     512kB time=00:00:29.00 bitrate= 144.6kbits/s speed=0.688x
frame=  709 fps= 17 q=25.0 size=     512kB time=00:00:29.45 bitrate= 142.4kbits/s speed=0.691x
frame=  721 fps= 17 q=25.0 size=     768kB time=00:00:29.95 bitrate= 210.0kbits/s speed=0.693x
frame=  733 fps= 17 q=25.0 size=     768kB time=00:00:30.45 bitrate= 206.6kbits/s speed=0.697x
frame=  744 fps= 17 q=25.0 size=     768kB time=00:00:30.91 bitrate= 203.5kbits/s speed=0.699x
frame=  755 fps= 17 q=25.0 size=     768kB time=00:00:31.37 bitrate= 200.5kbits/s speed=0.701x
frame=  766 fps= 17 q=25.0 size=     768kB time=00:00:31.83 bitrate= 197.6kbits/s speed=0.703x
frame=  777 fps= 17 q=25.0 size=     768kB time=00:00:32.29 bitrate= 194.8kbits/s speed=0.705x
frame=  788 fps= 17 q=25.0 size=     768kB time=00:00:32.75 bitrate= 192.1kbits/s speed=0.707x
frame=  799 fps= 17 q=25.0 size=     768kB time=00:00:33.20 bitrate= 189.5kbits/s speed=0.708x
frame=  810 fps= 17 q=25.0 size=     768kB time=00:00:33.66 bitrate= 186.9kbits/s speed=0.71x
frame=  821 fps= 17 q=25.0 size=     768kB time=00:00:34.12 bitrate= 184.4kbits/s speed=0.711x
[out#0/mp4 @ 0x64a5c118ed00] video:929kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 1.200532%
frame=  881 fps= 18 q=-1.0 Lsize=     940kB time=00:00:36.58 bitrate= 210.5kbits/s speed=0.753x
[libx264 @ 0x64a5c11903c0] frame I:4     Avg QP:13.80  size: 56321
[libx264 @ 0x64a5c11903c0] frame P:222   Avg QP:17.89  size:  2557
[libx264 @ 0x64a5c11903c0] frame B:655   Avg QP:22.68  size:   241
[libx264 @ 0x64a5c11903c0] consecutive B-frames:  0.8%  0.2%  0.0% 99.0%
[libx264 @ 0x64a5c11903c0] mb I  I16..4: 66.6% 18.2% 15.3%
[libx264 @ 0x64a5c11903c0] mb P  I16..4:  0.6%  0.1%  0.3%  P16..4:  4.2%  0.2%  0.1%  0.0%  0.0%    skip:94.6%
[libx264 @ 0x64a5c11903c0] mb B  I16..4:  0.0%  0.0%  0.0%  B16..8:  2.3%  0.0%  0.0%  direct: 0.0%  skip:97.6%  L0:53.9% L1:46.0% BI: 0.1%
[libx264 @ 0x64a5c11903c0] 8x8 transform intra:19.3% inter:42.4%
[libx264 @ 0x64a5c11903c0] coded y,uvDC,uvAC intra: 14.0% 15.6% 11.5% inter: 0.1% 0.1% 0.0%
[libx264 @ 0x64a5c11903c0] i16 v,h,dc,p: 22% 78%  0%  0%
[libx264 @ 0x64a5c11903c0] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 48% 10% 40%  2%  0%  0%  0%  0%  0%
[libx264 @ 0x64a5c11903c0] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 35% 41%  8%  2%  2%  3%  3%  2%  3%
[libx264 @ 0x64a5c11903c0] i8c dc,h,v,p: 70% 20% 10%  1%
[libx264 @ 0x64a5c11903c0] Weighted P-Frames: Y:0.0% UV:0.0%
[libx264 @ 0x64a5c11903c0] ref P L0: 86.0%  1.2%  9.3%  3.5%
[libx264 @ 0x64a5c11903c0] ref B L0: 63.0% 35.9%  1.1%
[libx264 @ 0x64a5c11903c0] ref B L1: 97.3%  2.7%
[libx264 @ 0x64a5c11903c0] kb/s:207.18
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_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Duration: 00:00:36.71, start: 0.000000, bitrate: 209 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, 207 kb/s, 24 fps, 24 tbr, 12288 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
[mp3 @ 0x60291a11a0c0] Estimating duration from bitrate, this may be inaccurate
Input #1, mp3, from '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/voiceover_word_boundary.mp3':
  Duration: 00:00:36.17, 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 (copy)
  Stream #1:0 -> #0:1 (mp3 (mp3float) -> aac (native))
Press [q] to stop, [?] for help
Output #0, mp4, to '/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, q=2-31, 207 kb/s, 24 fps, 24 tbr, 12288 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
  Stream #0:1: Audio: aac (LC) (mp4a / 0x6134706D), 24000 Hz, mono, fltp, 69 kb/s
    Metadata:
      encoder         : Lavc60.31.102 aac
size=       0kB time=N/A bitrate=N/A speed=N/A
[out#0/mp4 @ 0x60291a12fa80] video:929kB audio:278kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 1.835528%
size=    1229kB time=00:00:36.58 bitrate= 275.3kbits/s speed=75.4x
[aac @ 0x60291a1abc00] Qavg: 19257.057
{
  "ok": true,
  "manifest": {
    "candidate_id": "calendar-invite-login-trap-30s",
    "version": "v1.0.0-word-aligned-proof",
    "series": "Digital Red Flags",
    "title": "The calendar invite login trap",
    "script": "This calendar invite looks normal. The trap is the login button. Same meeting title. Same guest list. Same urgent tone. But the link points somewhere else. Before you sign in, slow down. First, check who sent it. Then check the link domain. Then notice any unusual login request. If the domain feels off, do not sign in from the invite. Open the service yourself. The fix is simple: trust the app you opened, not the button that rushed you.",
    "script_hash": "8360ab6a8f2241e9",
    "preview_mp4_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned.mp4",
    "preview_sha256": "723d3349c008eafad423d52026b8c200fc2c5ebdedf6c2ef4e4c6f5d4826a44b",
    "silent_mp4_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap-30s_preview_v1_word_aligned_silent.mp4",
    "audio_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/voiceover_word_boundary.mp3",
    "audio_sha256": "c6c8914a0245ef5e52500cf9d0646542c8b540a786a06314932e47940fd5cbdd",
    "word_cues_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/audio/word_cues.json",
    "caption_segments_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/caption_segments_word_aligned.json",
    "contact_sheet_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap_contact_sheet_v1.jpg",
    "subtitle_preview_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/calendar-invite-login-trap_subtitle_style_preview_v1.jpg",
    "qa_summary_path": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/preview_v1_word_aligned/qa_summary_preview_v1.json",
    "duration_seconds": 36.668,
    "resolution": "1080x1920",
    "fps": 24,
    "caption_policy": "static 3-7 word phrase segments with active spoken word highlight from EdgeTTS word boundaries",
    "blocked": {
      "wan": true,
      "i2v": true,
      "platform_api": true,
      "posting": true,
      "scheduling": true
    }
  },
  "review_package": "/tmp/autoshorts_proof_short_sprint31/calendar-invite-login-trap-30s/review_package_v1_word_aligned.json"
}


patch

SCRIPT = (
    "This calendar invite looks normal. The trap is the login button. "
    "Same meeting title. Same urgent tone. But the link points somewhere else. "
    "Before you sign in, slow down. Check who sent it. Check the link domain. "
    "Notice unusual login requests. If the domain feels off, do not sign in from the invite. "
    "Open the service yourself. The fix: trust the app you opened, not the button that rushed you."
)

SCRIPT = (
    "This calendar invite looks normal. The trap is the login button. "
    "Same meeting title. Same guest list. Same urgent tone. "
    "But the link points somewhere else. Before you sign in, slow down. "
    "First, check who sent it. Then check the link domain. Then notice any unusual login request. "
    "If the domain feels off, do not sign in from the invite. Open the service yourself. "
    "The fix is simple: trust the app you opened, not the button that rushed you."
)


--- a//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
+++ b//tmp/autoshorts_proof_short_sprint31/render_calendar_invite_preview_v1.py
@@ -41,11 +41,10 @@

 SCRIPT = (
     "This calendar invite looks normal. The trap is the login button. "
-    "Same meeting title. Same guest list. Same urgent tone. "
-    "But the link points somewhere else. Before you sign in, slow down. "
-    "First, check who sent it. Then check the link domain. Then notice any unusual login request. "
-    "If the domain feels off, do not sign in from the invite. Open the service yourself. "
-    "The fix is simple: trust the app you opened, not the button that rushed you."
+    "Same meeting title. Same urgent tone. But the link points somewhere else. "
+    "Before you sign in, slow down. Check who sent it. Check the link domain. "
+    "Notice unusual login requests. If the domain feels off, do not sign in from the invite. "
+    "Open the service yourself. The fix: trust the app you opened, not the button that rushed you."
 )
 SENTENCES = [s.strip() for s in re.split(r'(?<=[.!?])\s+', SCRIPT) if s.strip()]