Output:
[
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260525_192025_93a777df.png",
    "mtime": 1779729625.3366475,
    "size": 2029976,
    "sha256": "bdcbc300f918c07ebe4cb5620afa633ff7f8e02184f3d4f6d43c522745465baf",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171425_7e5c5015.png",
    "mtime": 1779722065.1538935,
    "size": 2342028,
    "sha256": "9ad93033d1b52a99a00ee402fe0bfd03ca38367020b5cee59325ed660e6b5151",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171324_5e5577a3.png",
    "mtime": 1779722004.194337,
    "size": 2409107,
    "sha256": "759952a3a2e5bd582b86c71a499243a9ed0fecf1fbbda936fd17dbb82d0cca83",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171159_68e5c2da.png",
    "mtime": 1779721919.6983407,
    "size": 2502677,
    "sha256": "5452c1609d4b5467757e457e5b4426a7ffbd218db92c750b29a907f9c8d1bf2b",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171106_30a3b93c.png",
    "mtime": 1779721866.1875107,
    "size": 1850047,
    "sha256": "cc45d1c4dbf591abfad2a060b9065a23b063984b3cbafdcbfeda974d88303c55",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171011_d48ab227.png",
    "mtime": 1779721811.1904747,
    "size": 1846484,
    "sha256": "fe77a8321cf5108c6891126df944b6bf3c56098904cc18786ca0b38734dddbc5",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170919_3ff53fa7.png",
    "mtime": 1779721759.0169618,
    "size": 1770763,
    "sha256": "858219ba9dc2356587060a0d317b2068a9af826ec790c12b2ee4007e006df69a",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170840_142e83a8.png",
    "mtime": 1779721720.6722121,
    "size": 2111685,
    "sha256": "1848b366679952bf38e0d6285554d6c3590d58c6d0f7efb6f102ea1b279db217",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170731_5038e2ce.png",
    "mtime": 1779721651.5509584,
    "size": 1819479,
    "sha256": "651b0ff2bccdb0041f28a484f9f6357398e34491844c25ce2b652661943f5591",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170637_53794aad.png",
    "mtime": 1779721597.7238116,
    "size": 1696594,
    "sha256": "7d3c2538b36671dda3c15b4bfacd58eb34deb908fadfc92a461164a0a5a27b24",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170531_da9173a6.png",
    "mtime": 1779721531.958457,
    "size": 1700699,
    "sha256": "6b674dd1f916fcf257a3f0d10e4988d3508a322097aa1f783aa12e8d0efed047",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163945_bde6f7ba.png",
    "mtime": 1779719985.3383734,
    "size": 2308943,
    "sha256": "a295e458b6dbf3fa2fa198a325c52a3b46c7054665bb9419234399ceef0235bd",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163829_a2a038ee.png",
    "mtime": 1779719909.6382785,
    "size": 1745510,
    "sha256": "70da5c3d820c5a1b28e8a6ad6afec09d713ec811a3ea2b7af89af94d38f95d69",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163710_c68f8acc.png",
    "mtime": 1779719830.9154792,
    "size": 1828729,
    "sha256": "52892b0c366981aba992cf016d55c1b091cb9c024baa34dfd9363cc50cab68aa",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  },
  {
    "path": "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_155120_1c40f80e.png",
    "mtime": 1779717080.070971,
    "size": 1780097,
    "sha256": "35e4d6f9b90be70e7b5b4a3c862595b37515cd701a1c5f9d8866afccd936e390",
    "resolution": "1024x1536",
    "png_chunks": [
      "IHDR",
      "caBX",
      "IDAT",
      "IEND"
    ],
    "pil_info_keys": [],
    "has_c2pa_or_synthid_or_provenance": false
  }
]


Explanation:
**Investigating caBX and C2PA**

I'm exploring the caBX chunk and whether it indicates CAI. I think C2PA might utilize the caBX chunk, possibly related to a "CAI claim box." It's essential to report when caBX is present, as it likely signifies provenance. I need to examine the chunk further. PNG files contain caBX, but PIL doesn’t expose keys. It's intriguing how the generated images retain caBX, even if processed through PIL. The standard metadata isn’t human-readable, but the presence of the provenance chunk is significant.

terminal

python - <<'PY'
from pathlib import Path
p=Path('/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260525_192025_93a777df.png')
data=p.read_bytes(); pos=8
while pos+8 <= len(data):
    ln=int.from_bytes(data[pos:pos+4],'big'); typ=data[pos+4:pos+8]
    payload=data[pos+8:pos+8+ln]
    print(typ.decode('latin1'), ln, payload[:80])
    pos += 12+ln
    if typ==b'IEND': break
PY


Output:
IHDR 13 b'\x00\x00\x04\x00\x00\x00\x06\x00\x08\x02\x00\x00\x00'
caBX 29087 b'\x00\x00q\x9fjumb\x00\x00\x00\x1ejumdc2pa\x00\x11\x00\x10\x80\x00\x00\xaa\x008\x9bq\x03c2pa\x00\x00\x00qyjumb\x00\x00\x00Gjumdc2ma\x00\x11\x00\x10\x80\x00\x00\xaa\x008\x9bq\x03urn:c2pa:'
IDAT 2000820 b'x\x01\xed\xc0\x03\xa0$Y\x96\xc6\xf1\xffw\xee\x8d\xc8\xcc\xa7rKc\xaem\xdb\xb6m\xdb\xb6m\xdb\xb6mi\x8c\x9e\x96J\xaf\x9e23"\xee\xf9v\xb7jz\xa6\x87;k\xd5\xaf\xbe\xdb\xbb\xbd\x93\x12\x159\r\x84$\x91\x99\x92\xc0\x92lgsH\x92\xecDH\x800'
IEND 0 b''


Explanation:
**Updating delivery manifest**

Alright, I need to update the manifest with the delivery metadata. I think using execute_code could be helpful here. I might need to load the manifest and then add telegram for the artifacts. I just want to make sure I'm following the right steps and capturing everything necessary for a smooth update. Let’s get into it and ensure everything is in order for this task!

from hermes_tools import terminal
print(terminal('python - <<\'PY\'\nfrom pathlib import Path\nimport json\nmanifest=Path(\'/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/manifest_preview_v2_caption_safe.json\')\ndata=json.loads(manifest.read_text())\nfor key in [\'preview_mp4\',\'qa_frame_sheet\',\'subtitle_style_preview\']:\n    data[\'artifacts\'][key].update({\n      \'telegram_delivery_attempted\': True,\n      \'telegram_delivery_verified\': True,\n      \'telegram_message_id\': \'5969\',\n      \'telegram_target\': \'telegram:JARVIS Command Center / topic 4850\'\n    })\ndata[\'thread_delivery\']={\'telegram_delivery_attempted\':True,\'telegram_delivery_verified\':True,\'telegram_message_id\':\'5969\',\'chat_id\':\'-1003907117629\',\'target\':\'telegram:JARVIS Command Center / topic 4850\'}\nmanifest.write_text(json.dumps(data,indent=2),encoding=\'utf-8\')\nprint(json.dumps({k:data[\'artifacts\'][k] for k in [\'preview_mp4\',\'qa_frame_sheet\',\'subtitle_style_preview\']}, indent=2))\nPY', timeout=30)['output'])


Output:
{
  "preview_mp4": {
    "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags-fake-invoice-payment-route-30s_preview_v2_caption_safe.mp4",
    "file_exists": true,
    "file_size": 967725,
    "sha256": "a94893693b02356487773798f3b306394595fd64dac82588b600e1134ee318b3",
    "media_type": "video/mp4",
    "duration": 31.32,
    "resolution": "1080x1920",
    "fps": "24/1",
    "telegram_delivery_attempted": true,
    "telegram_delivery_verified": true,
    "telegram_message_id": "5969",
    "telegram_target": "telegram:JARVIS Command Center / topic 4850"
  },
  "qa_frame_sheet": {
    "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags_caption_safe_contact_sheet_v2.jpg",
    "file_exists": true,
    "file_size": 232446,
    "sha256": "f84055c92e266fbc07c4bb577bbd0d6765835f46a00c4ef5da2c6de234be67eb",
    "media_type": "image/jpeg",
    "duration": "n/a",
    "resolution": "810x1440",
    "telegram_delivery_attempted": true,
    "telegram_delivery_verified": true,
    "telegram_message_id": "5969",
    "telegram_target": "telegram:JARVIS Command Center / topic 4850"
  },
  "subtitle_style_preview": {
    "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags_subtitle_style_preview_v2.jpg",
    "file_exists": true,
    "file_size": 353885,
    "sha256": "6ad056f7d8b9d0569313597241f8628cbe7b4b606eac5df3244347ff1e7972e6",
    "media_type": "image/jpeg",
    "duration": "n/a",
    "resolution": "1080x1920",
    "telegram_delivery_attempted": true,
    "telegram_delivery_verified": true,
    "telegram_message_id": "5969",
    "telegram_target": "telegram:JARVIS Command Center / topic 4850"
  }
}


Explanation:
**Checking git status and commits**

I’m thinking it’s time to check the git status since the user may want to commit changes. They didn’t specifically ask this time, but they did before, so it seems like a logical step to consider. Since there have been code changes, it’s likely a good idea to commit. I might also want to run all the tests. Let’s check our status first to see what’s up!

git status --short && pytest tests/test_caption_safe_zone.py tests/test_static_caption_segments.py tests/test_image_generation_policy.py tests/test_proof_short_mode.py tests/test_pilot_production_v2.py -q


Output:
M autoshorts/rendering/static_captions.py
?? autoshorts/config/image_generation_policy.json
?? autoshorts/production/image_generation_policy.py
?? docs/image_generation_guidelines.md
?? tests/test_caption_safe_zone.py
?? tests/test_image_generation_policy.py
........................                                                 [100%]
24 passed in 0.41s


git diff -- autoshorts/rendering/static_captions.py autoshorts/production/image_generation_policy.py autoshorts/config/image_generation_policy.json docs/image_generation_guidelines.md tests/test_caption_safe_zone.py tests/test_image_generation_policy.py | sed -n '1,260p'


Output:
diff --git a/autoshorts/rendering/static_captions.py b/autoshorts/rendering/static_captions.py
index 5fe273c..5d238fa 100644
--- a/autoshorts/rendering/static_captions.py
+++ b/autoshorts/rendering/static_captions.py
@@ -5,12 +5,13 @@ Caption rule for TrueTraceShorts previews/finals:
 - At least three words are visible per default segment whenever possible.
 - Only the active spoken word changes highlight.
 - The highlight moves left-to-right until the next phrase segment replaces it.
+- Caption layout must remain inside the mobile safe zone.
 """

 from __future__ import annotations

 from dataclasses import dataclass
-from typing import Iterable, Sequence
+from typing import Sequence

 @dataclass(frozen=True)
@@ -34,6 +35,42 @@ class StaticCaptionSegment:
         return " ".join(self.words)

+@dataclass(frozen=True)
+class CaptionLayoutConfig:
+    video_width: int = 1080
+    video_height: int = 1920
+    horizontal_margin_px: int = 60
+    bottom_margin_px: int = 260
+    max_width_ratio: float = 0.88
+    max_lines: int = 2
+    font_size_px: int = 68
+    min_font_size_px: int = 46
+    line_gap_px: int = 14
+    word_gap_px: int = 18
+    highlight_pad_x_px: int = 10
+    highlight_pad_y_px: int = 5
+
+    @property
+    def safe_width_px(self) -> int:
+        ratio_width = int(self.video_width * self.max_width_ratio)
+        margin_width = self.video_width - 2 * self.horizontal_margin_px
+        return min(ratio_width, margin_width)
+
+
+@dataclass(frozen=True)
+class CaptionLayout:
+    lines: tuple[tuple[str, ...], ...]
+    font_size_px: int
+    left_px: int
+    right_px: int
+    top_px: int
+    bottom_px: int
+    width_px: int
+    height_px: int
+    word_boxes: tuple[tuple[int, int, int, int], ...]
+    active_word_box: tuple[int, int, int, int] | None
+
+
 def build_static_caption_segments(
     word_cues: Sequence[WordCue],
     *,
@@ -94,6 +131,63 @@ def build_static_caption_segments(
     return tuple(segments)

+def build_safe_caption_segments(
+    word_cues: Sequence[WordCue],
+    *,
+    layout: CaptionLayoutConfig | None = None,
+    min_words: int = 3,
+    target_words: int = 6,
+    max_words: int = 7,
+) -> tuple[StaticCaptionSegment, ...]:
+    """Build phrase segments that are pre-split to fit the caption safe zone.
+
+    The function keeps the established 3–7 word phrase behavior, but refuses to
+    create a segment that would need more than two lines or exceed the safe
+    width at the minimum allowed font size. Very long tokens are handled by
+    dynamic shrink in layout, and if a phrase is still too wide the segment is
+    split earlier.
+    """
+    cfg = layout or CaptionLayoutConfig()
+    base_segments = build_static_caption_segments(
+        word_cues,
+        min_words=min_words,
+        target_words=target_words,
+        max_words=max_words,
+    )
+    safe_segments: list[StaticCaptionSegment] = []
+    for segment in base_segments:
+        pairs = [(idx, word_cues[idx]) for idx in segment.word_indices]
+        current: list[tuple[int, WordCue]] = []
+        for pair in pairs:
+            candidate = current + [pair]
+            candidate_words = tuple(cue.word for _, cue in candidate)
+            if len(candidate_words) <= max_words and _fits_caption_layout(candidate_words, cfg):
+                current = candidate
+                continue
+            if current:
+                safe_segments.append(_segment_from_chunk(current))
+                current = [pair]
+            else:
+                current = [pair]
+        if current:
+            # Try to merge a too-small tail back only when still safe.
+            if (
+                len(current) < min_words
+                and safe_segments
+                and len(safe_segments[-1].words) + len(current) <= max_words
+            ):
+                prev = safe_segments[-1]
+                merged_pairs = [(idx, word_cues[idx]) for idx in prev.word_indices] + current
+                merged_words = tuple(cue.word for _, cue in merged_pairs)
+                if _fits_caption_layout(merged_words, cfg):
+                    safe_segments[-1] = _segment_from_chunk(merged_pairs)
+                else:
+                    safe_segments.append(_segment_from_chunk(current))
+            else:
+                safe_segments.append(_segment_from_chunk(current))
+    return tuple(safe_segments)
+
+
 def _segment_from_chunk(chunk: Sequence[tuple[int, WordCue]]) -> StaticCaptionSegment:
     return StaticCaptionSegment(
         start=chunk[0][1].start,
@@ -129,3 +223,138 @@ def caption_state_at(word_cues: Sequence[WordCue], segments: Sequence[StaticCapt
         "active_word_index": active_index,
         "active_word": word_cues[active_index].word,
     }
+
+
+def layout_caption_segment(
+    words: Sequence[str],
+    *,
+    active_word: str | None = None,
+    config: CaptionLayoutConfig | None = None,
+) -> CaptionLayout:
+    cfg = config or CaptionLayoutConfig()
+    if not words:
+        raise ValueError("words must not be empty")
+
+    for size in range(cfg.font_size_px, cfg.min_font_size_px - 1, -2):
+        lines = _wrap_words(words, cfg, size)
+        metrics = _measure_lines(lines, cfg, size, active_word=active_word)
+        if len(lines) <= cfg.max_lines and metrics["width"] <= cfg.safe_width_px:
+            return _layout_from_metrics(lines, cfg, size, metrics)
+
+    # Last resort: return minimum-size layout; callers can split earlier.
+    lines = _wrap_words(words, cfg, cfg.min_font_size_px)
+    if len(lines) > cfg.max_lines:
+        lines = lines[: cfg.max_lines - 1] + (tuple(word for line in lines[cfg.max_lines - 1 :] for word in line),)
+    metrics = _measure_lines(lines, cfg, cfg.min_font_size_px, active_word=active_word)
+    return _layout_from_metrics(lines, cfg, cfg.min_font_size_px, metrics)
+
+
+def _fits_caption_layout(words: Sequence[str], cfg: CaptionLayoutConfig) -> bool:
+    layout = layout_caption_segment(words, active_word=words[0] if words else None, config=cfg)
+    return len(layout.lines) <= cfg.max_lines and layout.width_px <= cfg.safe_width_px
+
+
+def _font(size: int):
+    from PIL import ImageFont
+
+    for path in (
+        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
+    ):
+        try:
+            return ImageFont.truetype(path, size=size)
+        except OSError:
+            continue
+    return ImageFont.load_default()
+
+
+def _word_width(word: str, size: int) -> int:
+    from PIL import Image, ImageDraw
+
+    image = Image.new("RGB", (10, 10))
+    draw = ImageDraw.Draw(image)
+    bbox = draw.textbbox((0, 0), word, font=_font(size), stroke_width=2)
+    return bbox[2] - bbox[0]
+
+
+def _line_width(line: Sequence[str], cfg: CaptionLayoutConfig, size: int) -> int:
+    if not line:
+        return 0
+    return sum(_word_width(word, size) for word in line) + cfg.word_gap_px * (len(line) - 1)
+
+
+def _wrap_words(words: Sequence[str], cfg: CaptionLayoutConfig, size: int) -> tuple[tuple[str, ...], ...]:
+    lines: list[list[str]] = [[]]
+    for word in words:
+        current = lines[-1]
+        candidate = current + [word]
+        if current and _line_width(candidate, cfg, size) > cfg.safe_width_px:
+            lines.append([word])
+        else:
+            current.append(word)
+    return tuple(tuple(line) for line in lines if line)
+
+
+def _measure_lines(
+    lines: Sequence[Sequence[str]],
+    cfg: CaptionLayoutConfig,
+    size: int,
+    *,
+    active_word: str | None,
+) -> dict[str, object]:
+    line_height = int(size * 1.15)
+    widths = [_line_width(line, cfg, size) for line in lines]
+    width = max(widths) if widths else 0
+    height = len(lines) * line_height + max(0, len(lines) - 1) * cfg.line_gap_px
+    left = max(cfg.horizontal_margin_px, (cfg.video_width - width) // 2)
+    top = cfg.video_height - cfg.bottom_margin_px - height
+    word_boxes: list[tuple[int, int, int, int]] = []
+    active_box = None
+    for line_index, line in enumerate(lines):
+        line_width = _line_width(line, cfg, size)
+        x = max(cfg.horizontal_margin_px, (cfg.video_width - line_width) // 2)
+        y = top + line_index * (line_height + cfg.line_gap_px)
+        for word in line:
+            ww = _word_width(word, size)
+            box = (x, y, x + ww, y + line_height)
+            word_boxes.append(box)
+            if active_box is None and active_word == word:
+                active_box = (
+                    max(cfg.horizontal_margin_px, box[0] - cfg.highlight_pad_x_px),
+                    box[1] - cfg.highlight_pad_y_px,
+                    min(cfg.video_width - cfg.horizontal_margin_px, box[2] + cfg.highlight_pad_x_px),
+                    box[3] + cfg.highlight_pad_y_px,
+                )
+            x += ww + cfg.word_gap_px
+    return {
+        "width": width,
+        "height": height,
+        "left": left,
+        "top": top,
+        "word_boxes": tuple(word_boxes),
+        "active_word_box": active_box,
+    }
+
+
+def _layout_from_metrics(
+    lines: Sequence[Sequence[str]],
+    cfg: CaptionLayoutConfig,
+    size: int,
+    metrics: dict[str, object],
+) -> CaptionLayout:
+    left = int(metrics["left"])
+    top = int(metrics["top"])
+    width = int(metrics["width"])
+    height = int(metrics["height"])
+    return CaptionLayout(
+        lines=tuple(tuple(line) for line in lines),
+        font_size_px=size,
+        left_px=left,
+        right_px=left + width,
+        top_px=top,
+        bottom_px=top + height,


git add autoshorts/rendering/static_captions.py autoshorts/production/image_generation_policy.py autoshorts/config/image_generation_policy.json docs/image_generation_guidelines.md tests/test_caption_safe_zone.py tests/test_image_generation_policy.py && git commit -m "fix: enforce caption safe zones and image policy"


Output:
[strategy-refactor-v2 cf66672] fix: enforce caption safe zones and image policy
 6 files changed, 468 insertions(+), 1 deletion(-)
 create mode 100644 autoshorts/config/image_generation_policy.json
 create mode 100644 autoshorts/production/image_generation_policy.py
 create mode 100644 docs/image_generation_guidelines.md
 create mode 100644 tests/test_caption_safe_zone.py
 create mode 100644 tests/test_image_generation_policy.py


Explanation:
**Clarifying file status**

I’m noticing there are six files mentioned, but it says “not included”? Is that what static, docs, tests, config, and policy add up to? I’m wondering if something is missing. I should check if my git status is clean to ensure everything is properly tracked. That’ll help me understand what’s really going on here. Let’s see if I can figure it out!

git status --short && git log -1 --oneline


Output: cf66672 fix: enforce caption safe zones and image policy


Sir, **Sprint 3.1 — Image Provider Audit + Subtitle Safe-Zone Fix ist ausgeführt.**

Kurzfassung:
**Image Provider ist verifiziert OpenAI/Codex. `gpt-image-2-high` ist verfügbar und funktioniert. Captions sind jetzt layout-getestet und in Preview v2 innerhalb der Safe-Zone.** Kein Wan, kein I2V, kein T2V, kein Posting. Wie gewünscht: nützlich statt hübsch-vernebelt.

## 1. Image Provider Audit Result

Aktuelle Hermes-Konfiguration nach Audit/Fix:

```text
image_gen.provider = openai-codex
image_gen.model    = gpt-image-2-high
```

Vor Sprint 3.1 war gesetzt:

```text
image_gen.provider = openai-codex
image_gen.model    = gpt-image-2-medium
```

Ich habe nach erfolgreichem Capability-Test auf **`gpt-image-2-high`** umgestellt, damit künftige Premium-Styleframes standardmässig die beste verfügbare GPT-Image-Stufe nutzen.

Main model/provider der laufenden Hermes-Session:

```text
model.provider = openai-codex
model.default  = gpt-5.5
base_url       = https://chatgpt.com/backend-api/codex
```

## 2. Verfügbare Image-Provider / Fallbacks

Registrierte Image-Provider:

```text
openai-codex
available: true
default_model: gpt-image-2-medium
models:
- gpt-image-2-low
- gpt-image-2-medium
- gpt-image-2-high
```

```text
openai
available: false
reason: kein separater OpenAI API Key aktiv
models:
- gpt-image-2-low
- gpt-image-2-medium
- gpt-image-2-high
```

```text
xai
available: false
models:
- grok-imagine-image
- grok-imagine-image-quality
```

Fallback-Logik im Hermes-Registry-Code:

- Wenn `image_gen.provider` explizit gesetzt ist, gewinnt dieser Provider.
- Wenn nicht gesetzt, würde Hermes bei mehreren Providern Legacy/Fallback-Logik nutzen.
- Für uns ist jetzt explizit gepinnt:

```text
allowed provider: openai-codex
```

## 3. Wurde `gpt-image-2-medium` wirklich genutzt?

Ja.

Die letzten Styleframe-/Image-Runs liegen unter:

```text
/home/agent/.hermes/cache/images/
```

Beispiele:

```text
openai_codex_gpt-image-2-medium_20260525_171425_7e5c5015.png
openai_codex_gpt-image-2-medium_20260525_171324_5e5577a3.png
openai_codex_gpt-image-2-medium_20260525_171159_68e5c2da.png
...
```

Das belegt:

```text
provider: openai-codex
model/tier: gpt-image-2-medium
storage: ~/.hermes/cache/images/
```

Keine Hinweise, dass die letzten Styleframes lokal, über ComfyUI, Wan, Qwen-Image oder andere lokale Generatoren erzeugt wurden.

## 4. Höherwertiger Modus verfügbar?

Ja.

Verfügbar laut Provider-Katalog:

```text
gpt-image-2-low
gpt-image-2-medium
gpt-image-2-high
```

`gpt-image-2-high` ist der höchste verfügbare Modus im Hermes/OpenAI-Codex Image Provider.

Kein separates `images-with-thinking` Profil im aktuellen Provider-Code gefunden. Der Codex-Backend-Pfad nutzt intern:

```text
Responses API image_generation tool
API_MODEL = gpt-image-2
quality = low | medium | high
host chat model = gpt-5.4
```

## 5. Kontrollierter Capability-Test

Ausgeführt: **1 Testbild**, neutral, keine Batch-Orgie.

Result:

```text
success: true
provider: openai-codex
model: gpt-image-2-high
quality: high
size: 1024x1536
aspect_ratio: portrait
elapsed: 60.93s
```

Testbild:

```text
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260525_192025_93a777df.png
```

Audit-Kopie:

```text
/tmp/autoshorts_proof_short_sprint30/image_provider_audit/capability_test_openai_codex_gpt-image-2-high_20260525_192025_93a777df.png
```

Metadata:

```text
file_size: 2029976 bytes
sha256: bdcbc300f918c07ebe4cb5620afa633ff7f8e02184f3d4f6d43c522745465baf
resolution: 1024x1536
```

## 6. Provenance / C2PA / SynthID

PNG-Chunk-Inspection der OpenAI/Codex-Bilder zeigt:

```text
IHDR
caBX
IDAT
IEND
```

Der `caBX` Chunk enthält JUMBF/C2PA-Struktur, u. a.:

```text
jumb
jumdc2pa
urn:c2pa:
```

Bewertung:

- **C2PA/Content Authenticity Provenance ist im originalen PNG-Cache vorhanden.**
- PIL zeigt keine normalen EXIF/Text-Keys an.
- Die Roh-PNGs aus `~/.hermes/cache/images/` behalten den `caBX` Chunk.
- Abgeleitete JPEGs/MP4s aus Renderer/Contact-Sheets erhalten diese PNG-Provenance-Metadaten nicht zuverlässig. Erwartbar, leider. Medienpipelines sind da selten Gentlemen.

## 7. Image Generation Guidelines erstellt

Erstellt:

```text
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/image_generation_guidelines.md
```

Enthält:

- Prompt Template
- Quality Checklist
- Rejection Criteria
- Styleframe Budget Policy
- Scene Consistency Rules
- Text/Logo/Pseudo-Writing Blocker

Wichtige Regel:

```text
Fake-Invoice / Email / Payment Route Mockups bleiben deterministisch renderer-owned.
AI Images nur für Premium-Styleframes/Keyframes, nicht für einfache UI-/Invoice-Mockups.
```

## 8. Image Generation Policy ergänzt

Code/config ergänzt:

```text
autoshorts/production/image_generation_policy.py
autoshorts/config/image_generation_policy.json
```

Policy:

```text
allowed_image_provider = openai-codex
preferred_image_model = best_available_gpt_image_model
preferred_quality = highest_available_reasonable
max_styleframes_per_scene_without_approval = 3
max_retries_per_styleframe = 1
no_random_batches = true
no_ai_images_for_mockups_when_deterministic_renderer_is_better = true
```

Commit:

```text
cf66672 fix: enforce caption safe zones and image policy
```

## 9. Subtitle Safe-Zone Fix

Code ergänzt in:

```text
autoshorts/rendering/static_captions.py
```

Neue Funktionen/Modelle:

```text
CaptionLayoutConfig
CaptionLayout
build_safe_caption_segments()
layout_caption_segment()
```

Harte Caption-Regeln umgesetzt:

```text
max_width_ratio: 0.86
horizontal_margin_px: 70
bottom_margin_px: 310
max_lines: 2
font_size_px: 66
min_font_size_px: 48
preferred segment length: 4–7 words
```

Validiert:

```text
caption_layout_validation.passed = true
violations = []
```

Aktives Wort bleibt innerhalb der Bounds. Kein Caption-Text läuft links/rechts aus dem Safe-Bereich.

## 10. Tests ergänzt

Neue Tests:

```text
tests/test_caption_safe_zone.py
tests/test_image_generation_policy.py
```

Abgedeckt:

- long phrase wraps into 2 lines
- caption never exceeds safe width
- active word highlight never exceeds safe bounds
- no word clipped left/right
- max 2 lines enforced
- phrase split before >2 lines
- bottom safe-zone respected
- domain strings like `example-company.com` handled safely
- image provider policy pins OpenAI/Codex
- no random batches / deterministic mockups preference

Pytest Ergebnis:

```text
24 passed in 0.41s
```

Getestet:

```text
tests/test_caption_safe_zone.py
tests/test_static_caption_segments.py
tests/test_image_generation_policy.py
tests/test_proof_short_mode.py
tests/test_pilot_production_v2.py
```

## 11. Preview v2 erstellt

Candidate:

```text
digital-red-flags-fake-invoice-payment-route-30s
```

Version:

```text
preview_v2_caption_safe
```

Änderungen:

- gleiche Story
- gleiche Voiceover
- deterministische screen/mockup visuals
- Caption Layout gefixt
- keine AI-Bilder
- kein Wan
- kein I2V/T2V
- kein Posting

Manifest:

```text
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/manifest_preview_v2_caption_safe.json
```

## 12. Telegram Delivery

Gesendet an:

```text
telegram:JARVIS Command Center / topic 4850
```

Telegram Status:

```text
telegram_delivery_attempted: true
telegram_delivery_verified: true
telegram_message_id: 5969
```

## 13. Artefakte

Preview MP4:

```text
file_path:
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags-fake-invoice-payment-route-30s_preview_v2_caption_safe.mp4

file_exists: true
file_size: 967725 bytes
sha256: a94893693b02356487773798f3b306394595fd64dac82588b600e1134ee318b3
duration: 31.32s
resolution: 1080x1920
fps: 24/1
telegram_delivery_attempted: true
telegram_delivery_verified: true
telegram_message_id: 5969
```

QA Frame Sheet:

```text
file_path:
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags_caption_safe_contact_sheet_v2.jpg

file_exists: true
file_size: 232446 bytes
sha256: f84055c92e266fbc07c4bb577bbd0d6765835f46a00c4ef5da2c6de234be67eb
resolution: 810x1440
telegram_delivery_attempted: true
telegram_delivery_verified: true
telegram_message_id: 5969
```

Subtitle Style Preview:

```text
file_path:
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags_subtitle_style_preview_v2.jpg

file_exists: true
file_size: 353885 bytes
sha256: 6ad056f7d8b9d0569313597241f8628cbe7b4b606eac5df3244347ff1e7972e6
resolution: 1080x1920
telegram_delivery_attempted: true
telegram_delivery_verified: true
telegram_message_id: 5969
```

## 14. QA Ergebnis

Caption Safe-Zone:

```text
pass
```

Visuelle QA:

- keine Subtitle-Clips links/rechts
- max. 2 Zeilen
- aktive Wörter bleiben innerhalb der Safe-Zone
- Bottom-Safe-Zone eingehalten
- lange Phrasen wurden gesplittet:
  - `Before you pay check / three`
  - `things sender domain / urgency language`
  - `Verify through a second / channel`
- kein Caption-Kasten
- keine Vollbreiten-Bar
- white/yellow active-word style bleibt erhalten

## 15. Nicht ausgeführt

```text
wan_used: false
i2v_used: false
t2v_used: false
full_final: false
new_styleframe_batch: false
new_ai_images_for_preview: false
posting: false
platform_social_api: false
scheduling: false
```

Einzige Bildgeneration in Sprint 3.1 war der erlaubte **1-Bild Capability-Test**.

## 16. Offene Punkte

- Hermes config ist auf `gpt-image-2-high` gesetzt. Falls ein langlebiger Gateway-Prozess alte Image-Tool-Config cached, wäre später `/restart` oder `/reset` sinnvoll.
- Provenance/C2PA bleibt im originalen PNG-Cache erhalten, aber nicht garantiert in abgeleiteten JPEG/MP4-Artefakten.
- Für zukünftige Premium-Styleframes sollten wir nur noch mit klarer Scene Role + Rejection Criteria generieren, maximal 3 Varianten pro Szene ohne neue Freigabe.

## 17. Nächste mögliche Kommandos

Wenn Preview v2 passt:

```text
APPROVED_PROOF_SHORT digital-red-flags-fake-invoice-payment-route-30s
```

Wenn überarbeiten:

```text
REVISE_PROOF_SHORT digital-red-flags-fake-invoice-payment-route-30s <field> "Änderungswunsch"
```

Wenn später manuelle Upload-Vorbereitung erlaubt ist:

```text
APPROVED_FOR_MANUAL_POSTING_PACK digital-red-flags-fake-invoice-payment-route-30s
```


Sprint 3.2 — Manual Upload Pack

Upload-ready MP4:

Manual Posting Pack JSON:

Manual Posting Pack Markdown:

Analytics Template CSV:

Analytics Template JSON:

QA Summary JSON:

QA Contact Sheet:


social-media-shorts-automation

---
name: social-media-shorts-automation
description: Design, generate, review, publish, and optimize automated short-form social videos across YouTube Shorts, TikTok, Instagram Reels, and related channels.
version: 1.0.0
tags: [shorts, tiktok, youtube-shorts, reels, creator-monetization, content-automation, social-media]
---

# Social-Media Shorts Automation

Reference note: see `references/controlled-pilot-production-v2.md` for the controlled first-video workflow: resource tiers, media-generation gates, pilot package shape, Wan prompt planning without generation, and no auto-posting. See `references/pilot-preview-render-v2.md` for the next approval step: `APPROVED_FOR_PREVIEW_RENDER`, EdgeTTS preview audio, time/word cue generation, deterministic 9:16 animatic MP4, checksums, and Telegram media delivery without Wan/AI-image/platform side effects. See `references/static-active-word-captions-and-final-pilot-render.md` for the corrected caption style and controlled final pilot render pattern: static 3–7 word phrase segments with only the active spoken word highlighted, hash-bound final render gates, one Wan text-free background layer, deterministic overlays, and draft-only manual posting packages. If a final render is rejected as preview-overlay-dominated or not visual-first, follow `references/visual-first-final-render-repair.md`: mark the render rejected/not-postable, fix Wan output parsing, enforce final overlay limits, produce only a short visual proof under `APPROVED_FOR_VISUAL_PROOF`, and do not run another full final until the visual direction is accepted. If even the visual proof is rejected as abstract/generic/dark or “blind T2V”, switch to `references/keyframe-first-final-visuals.md`: plan and approve start/end/style keyframes first, then run only a short I2V/first-last-frame proof with minimal renderer-owned overlays. If the user says the production method itself is wrong, keyframes are not I2V-ready, or an animatic was incorrectly substituted for a failed Wan proof, follow `references/visual-production-reset-i2v-first.md`: reject/audit the proof, stop content rendering, run technical I2V sanity first, then generate at most three styleframes for direction review. If deterministic layout proofs are useful as composition blueprints but rejected as too wireframe/PowerPoint for final style, switch to `references/premium-ai-styleframe-direction.md`: use the layouts only as mechanism blueprints, generate at most 3 tightly briefed premium text-free AI styleframes, deliver a contact sheet with verified media metadata, and keep Wan/I2V/endframes/full-final blocked until style direction approval. If those premium styleframes are better quality but still too abstract, switch to `references/real-life-premium-styleframes.md`: anchor the mechanism in a realistic work/meeting/desk scene, keep all readable overlays renderer-owned, generate at most 3 new real-life styleframes plus contact sheet, and still block Wan/I2V/endframes/full-final until explicit approval. If the user approves one or more real-life styleframe directions but wants to compare A/B/C before Wan, use `references/prerelease-animatic-preview.md`: build only a small AI-keyframe + deterministic-motion animatic per approved direction, reuse existing voiceover/captions, deliver MP4/contact/QA sheets with metadata, and keep Wan/I2V/final/posting blocked. If the user then rejects the renderer-added circles/arrows/labels/boxes/decision-target markers as destroying the premium look, switch to `references/clean-image-cut-no-overlay-pilot.md`: render only AI images + voiceover + clean subtitles, with a hard subtitles-only NoOverlayPolicy and no Wan/I2V/full-final/posting. If the user performs a strategic reset because the pilot is pretty but semantically weak, stop optimizing that candidate and follow `references/proof-short-real-problem-reset.md`: archive the old pilot as a learning case, enforce the Pretty Empty Visual Blocker, and build a screen/mockup-led Proof Short around an immediately visible real viewer problem before any premium/Wan production. If the next review focuses on image provider quality/provenance or subtitles running off-screen, follow `references/image-provider-audit-and-caption-safezone.md`: audit the configured image provider/model/tier from config + provider registry + cache metadata, run at most one neutral capability test, keep UI/invoice mockups deterministic, and add tested caption layout safe-zone bounds before re-rendering.

Use this skill when building or operating systems that generate short-form social videos, especially faceless or semi-automated pipelines for YouTube Shorts, TikTok, Instagram Reels, Facebook Reels, Pinterest video pins, X video, or LinkedIn video.

## Core principle

Treat the system as a **Shorts Growth Engine** / **semi-automatic Retention Studio**, not merely a renderer. A working video generator can still fail if it produces generic, low-retention, AI-slop content. Optimize for audience identity, hook quality, retention mechanics, visual mechanism reveals, platform fit, and analytics feedback. For the user's strongest strategic direction, see `references/retention-studio-strategy.md`.

For this user's AutoShorts workflows, approval previews should be German for concept comprehension, while final social-media videos should remain English unless explicitly changed. Treat preview legibility as a quality gate: readable font sizes, pixel-width-aware line wrapping, no clipped words, and no overcrowded cards.

For the user's TrueTraceShorts / AutoShortsBot strategy, do **not** default to a narrow AI/prompt-tips channel. The current strategic direction is broader: explain hidden digital everyday mechanisms — AI, work, attention, decisions, scams, productivity, workflows, social search — and end each video with one small system rule that fixes the outcome. Use the frame `Trace the hidden system. Fix the outcome.` AI should remain an important pillar, but normally only about 30–40% of the topic mix, not the whole product. See `references/truetrace-strategic-neuausrichtung.md`.

When the user asks to start implementation work on this strategy, especially “Sprint 1: Strategic Spine Refactor”, follow `references/truetrace-strategic-spine-refactor-v2.md`: implement a side-effect-free V2 spine in parallel to existing V1 models, with mandatory strategic fields, hard quality gates, anti-AI-slop blockers, hash/version-bound ReviewPackageV2, and tests before integration with renderers or platform workflows.

When the user asks for “Sprint 1.5” or to make the V2 spine operational without production, follow `references/truetrace-v2-operational-bridge.md`: first secure/commit the Sprint-1 baseline, then add only minimal side-effect-free bridges (`to_candidate_v2`, `workflow_v2`, `demo_review_batch_v2`), deterministic ReviewPackageV2 JSON, strict approval parsing, calibrated non-generous scoring, anti-example/human-texture gates, minimal manual claim/disclosure stubs, LinkedIn manual metadata, and a Remotion-as-later-spike note. Keep it capped at 3 demo candidates and avoid rendering, platform APIs, dashboards, auto-posting, or 30-day batches.

## Retention-Studio / Mechanism-Reveal Pipeline

For quality-first automated Shorts, especially Hidden Money & AI Systems concepts, prefer the pipeline documented in `references/retention-studio-pipeline-pattern.md`:

```text
strategy → scored ideas → mechanism explainer → shot plan → keyframe/I2V prompt plan → seeded clip jobs → clip review → deterministic final renderer
```

When implementing a new repo or core pipeline, first scaffold the approval-first typed artifact chain in `references/approval-first-retention-studio-core.md`:

```text
ContentPillar → ContentBrief → ScriptDraft → VideoCandidate → TestBatch → ReviewBatch → Telegram ReviewDelivery → ShotPlan
```

Hard rule for this class: AI-video clips provide photorealistic texture and motion only. Captions, numbers, labels, arrows, UI text, price/risk/margin layers, and factual claims are renderer-owned. Final content should keep `static_card_limit = 0`, use visual changes every 1–2 seconds, and reject technically successful clips that do not serve the retention beat.

Before video generation, build an inspectable content-to-review spine: `ContentBrief → ScriptDraft → CandidateDraft → VideoCandidate → TestBatch → ReviewBatch → Telegram ReviewDelivery`. Keep it deterministic, side-effect free, and human-approval-first so hooks, retention beats, visual concepts, policy notes, and review copy can be optimized before spending GPU/API time. See `references/content-to-review-pipeline.md` for the implementation pattern and guardrails.

Hard rule for this class: AI-video clips provide photorealistic texture and motion only. Captions, numbers, labels, arrows, UI text, price/risk/margin layers, and factual claims are renderer-owned. Final content should keep `static_card_limit = 0`, use visual changes every 1–2 seconds, and reject technically successful clips that do not serve the retention beat.

## Recommended workflow

1. **Define channel position**
   - Niche(s): e.g. life optimization, AI life systems, Christian faith/FaithTok.
   - Target audience and emotional problem.
   - Language and tone.
   - Whether the brand is faceless, AI-persona-led, or creator-led.
   - No-go topics and theological/ethical boundaries if faith-related.

2. **Generate test ideas before code-heavy automation**
   - Create 20–30 candidate ideas across 2–4 content pillars.
   - For each: hook, script kernel, visual concept, caption, CTA, risk/quality score.
   - Select a small first batch (e.g. 3 per pillar) for real testing.
   - Before renderers or external AI APIs, add a deterministic content-development core: `ContentPillar → ContentBrief → ScriptDraft → CandidateDraft → VideoCandidate`. See `references/content-development-core.md`.

3. **Use hook-first, retention-led scripting**
   - First 1–2 seconds must contain a clear tension, identity statement, pain point, contrarian line, or immediate self-recognition.
   - Prefer one idea per video over generic “4 tips” unless the list format is the explicit series.
   - Avoid generic advice viewers have already heard unless framed with a new angle, concrete payoff, or strong story.
   - For TrueTraceShorts, prefer the structure `visible fail → identification → mechanism → fix → before/after → memorable takeaway` over generic AI-tip narration. Every video must answer: what goes wrong, why it happens, what rule fixes it, what sentence sticks, why viewers save/share it, and why they follow the series.
   - For unvalidated first pilots, apply the `Pretty Empty Visual Blocker`: if the first frame is beautiful but does not show a concrete viewer pain without voiceover, block the concept and switch to a Proof Short. Do not keep polishing abstract AI visuals, glass metaphors, or generic laptop B-roll before validating the real problem. See `references/proof-short-real-problem-reset.md`.
   - Reward hooks with direct address, specificity, open loops, tension, and a visible payoff; reject slow intros such as “In this video...”.
   - See `references/retention-led-shorts.md` for scoring signals, penalties, and voiceover finalization rules.

4. **Quality gate before rendering**
   Score drafts on:
   - hook strength
   - novelty
   - emotional pull
   - clarity
   - retention risk
   - comment/share potential
   - AI-slop risk
   - platform/policy risk
   - first-frame clarity
   - mechanism value
   - practical payoff
   - save/share/follow reason
   - trust/human texture
   - series fit and saturation defense
   Render only drafts above the chosen threshold, e.g. 8/10.
   For TrueTraceShorts, use the 100-point scoring and hard blockers in `references/truetrace-strategic-neuausrichtung.md`: block generic AI hooks, pure text first frames, unsourced claims, missing before/after, missing human texture, unclear AI disclosure, and candidates too similar to recent videos.

5. **Visual strategy**
   - Avoid obvious AI artifacts, fake UI text, illegible generated lettering, generic charts, and soulless stock visuals.
   - Prefer high-contrast kinetic captions, visible hook text in first seconds, relatable B-roll, screen recordings for AI/tutorial content, phone/screen close-ups, before/after transformations, cards, and timer visuals.
   - Use pattern interrupts every 2–3 seconds for retention.
   - Before rendering, generate a shot plan from the finalized voiceover: first-frame hook, pattern interrupt, core explanation, screen/card visual, CTA, caption beats, and asset type.
   - Before writing a renderer, convert shot plans into deterministic render manifests: machine-readable timeline items, asset placeholders, caption tracks, style constraints, and approval constraints. See `references/render-manifest-pipeline.md`.
   - Treat this as a deterministic intermediate artifact, not optional prose. See `references/retention-render-planning.md` for the five-scene shot-plan pattern and renderer-facing fields.
   - For the first video output, build a manifest-driven preview renderer before any AI-video or polished asset pipeline: first create a side-effect-free local preview plan, then write deterministic PNG scene cards, then build a side-effect-free FFmpeg concat plan, and only then call FFmpeg from an explicit renderer boundary. The boundary should report side effects such as `write_png`, `write_concat`, `call_ffmpeg`, and `write_mp4`; keep generated PNG/MP4 artifacts out of Git. See `references/manifest-preview-renderer.md`.
   - When the user rejects static/slideshow output, add an `asset_plan.v2` layer that maps each scene to motion-led local asset modes such as `cinematic_ai_video`, `screen_recording_simulation`, `mechanism_animation`, or `kinetic_text_overlay` with no baked text. If the user requires a free workflow, do not depend on paid video-credit tools; use local procedural motion and optional free NVIDIA LLM text refinement. See `references/ai-video-asset-planning.md`.
   - For hybrid workflows, add an imported-clip assembly layer: provider handoff prompts create short manual AI-video clips, humans drop them into deterministic `data/imported_ai_clips/<slug>/scene_XX.mp4` paths, and the final renderer combines imported clips with local mechanism animations/captions. See `references/imported-ai-clip-assembly.md`.
   - For local Wan2.2 quality workflows, prefer keyframe/image-to-video over raw text-to-video: generate/select a clean keyframe, run native Wan2.2 I2V variants, review clips bluntly for subject clarity/motion/artifacts, then add all labels/numbers/captions via deterministic renderer. See `references/wan-i2v-retention-studio.md`.
   - For final renders, Wan must carry the visual scene, not merely provide a blurred/looped background underneath preview cards. Use full-bleed, scene-specific Wan clips, minimal renderer-owned overlays, and a final overlay policy that blocks preview frames, large UI cards/textboxes, debug bars, and overlay overload. If architecture is rejected, create a short visual proof first; see `references/visual-first-final-render-repair.md`.
   - If a visual proof still looks like blind text-to-video or random abstract AI output, stop T2V and move to a keyframe-first workflow: generate/approve 1–2 text-free start/end/style keyframes for the scene, then attempt only a short Wan I2V/first-last-frame proof. See `references/keyframe-first-final-visuals.md`.
   - When using NVIDIA Build/API Catalog for keyframes, prefer hosted `ai.api.nvidia.com` calls before considering self-hosted NIM deployment; implement a configurable model fallback chain, store the key as a runtime secret, and inspect/clean logos, emblems, license plates, and fake text before I2V. See `references/nvidia-keyframe-generation.md`.

6. **Social-media draft and content-preview approval packages before TTS/rendering polish**
   - Before TTS, platform APIs, or polished video generation, build a typed `SocialMediaDraft` from the approved candidate plus render manifest/preview.
   - Include platform targets, title variants, caption, hashtags, CTA, hook, duration, quality/risk summary, and preview reference.
   - Render this into a Telegram-safe approval message and wrap it in a side-effect-free delivery plan with `requires_human_approval=True` and `side_effects=()`.
   - After a local MP4 timing preview exists, combine the MP4 path plus social draft review into a side-effect-free content preview approval package. This package should carry `approval_language=de`, `final_video_language=en`, explicit approval commands, no external calls, and no side effects.
   - Add local demo CLIs that print metadata first, then review text; draft-only CLIs must not write files, call renderers, call Telegram, or access credentials. MP4 preview CLIs may write local artifacts and call FFmpeg only at explicit renderer boundaries that report side effects.
   - See `references/social-draft-approval-package.md` and `references/content-automation-patterns.md` for concrete artifact sequence, user-specific language rules, and external workflow patterns.

7. **Human approval loop before posting**
   - Send preview to the user through the requested channel, usually Telegram.
   - Include: video or preview reference, title, caption, hashtags, platform recommendation, duration, quality/risk score.
   - When sending multiple preview videos, verify and label distinct artifacts before delivery: candidate ID/title, absolute path, duration, and preferably checksum/hash. If several previews are visually similar timing cards, do not spam them as separate unlabeled media; send a concise indexed list first or bundle with clear labels so the user can tell them apart.
   - Wait for explicit approval such as `Freigabe alle`, `Freigabe YouTube`, `Freigabe TikTok`, `Ablehnen`, or `Ändern: ...`.
   - Do not publish automatically unless the user has explicitly authorized that workflow.

8. **TTS / voice layer after draft approval**
   - Treat TTS as a later layer, not the starting point for a first post draft.
   - Decide voice, final voiceover script, words-per-second, scene timing, caption sync, audio format, provider, and separate voice approval only after the social draft package is reviewable.
   - This avoids spending TTS/rendering effort on unapproved hooks, captions, or platform positioning.
  - For final-near previews, keep one copy source of truth: final voiceover → script draft/retention beats → shot plan → top card text → scene captions → subtitle track → TTS audio. Do not mix old approval-card text with new spoken/subtitle text.
  - If the user says the result is too abstract/abgehoben, rewrite around a concrete beginner-friendly everyday failure example before polishing visuals. Prefer “one weird email arrives” / missing field / unclear request / wrong confident action over jargon such as `exception path`, `input boundary`, or `output standard`. See `references/beginner-friendly-audio-caption-alignment.md`.
  - If the user says subtitles are irritating or not simultaneous with voiceover, treat this as a blocking quality issue: use forced alignment or simplify captions before sending another final-near clip. For this user's AutoShorts/TrueTraceShorts default, do **not** show one word alone and do **not** slide a changing word-window every word. Use static phrase segments: keep 3–7 words fixed on screen while only the currently spoken word highlights/pulses left-to-right; replace the full segment only at a sentence/phrase boundary. See `references/static-active-word-captions-and-final-pilot-render.md`.
  - If deterministic layouts explain the mechanism but look like wireframes/PowerPoint, do not promote them to final style. Treat them as composition blueprints and run a small, tightly prompted premium AI styleframe pass instead: AI supplies glassmorphism/product-film quality and atmosphere; the renderer still owns labels, arrows, captions, facts, and final assembly. See `references/premium-ai-styleframe-direction.md`.
  - If premium AI styleframes improve quality but still feel like abstract SaaS symbolism, do not continue iterating abstract funnels/rings. Move to real-life anchored work scenes: desk/laptop, empty meeting room/glass board, or project table/work evidence, with only subtle glass/holographic mechanism overlays. See `references/real-life-premium-styleframes.md`.
  - If real-life styleframe directions are accepted but the user has not approved Wan/I2V yet, insert a pre-release animatic comparison step rather than jumping to endframes or video generation. Create small per-direction AI keyframe sets, deterministic push-ins/crossfades, existing voiceover, static active-word captions, contact sheets, and QA frame sheets; report Telegram delivery metadata. See `references/prerelease-animatic-preview.md`.
  - If the user rejects added visual explanation overlays as making the result look like a technical explainer board, treat this as a hard style failure. For the clean pilot, use only AI images, voiceover, and clean subtitles; block circles, arrows, labels, boxes, connector lines, decision-target markers, debug/diagram elements, and caption boxes/bars. See `references/clean-image-cut-no-overlay-pilot.md`.
  - For mechanism-reveal final renders, use the hybrid pattern in `references/final-motion-hybrid-rendering.md`: ChatGPT Image/Codex or similar tools create text-free scene backgrounds only; deterministic renderer owns cards, labels, arrows, subtitles, and FFmpeg assembly. Use compact phrase-boundary subtitle cues before 1080 export.

9. **Publish and measure**
   Track at minimum:
   - platform, video id/url, niche, format, hook, duration
   - views at 2h/24h/7d
   - likes, comments, shares, saves, follows
   - retention metrics if available
   Use results to generate the next batch.

## Platform duration/monetization notes

See `references/preview-media-delivery-guardrails.md` for avoiding duplicate-looking/unlabeled preview-video spam in Telegram review workflows.

See `references/truetrace-strategic-spine-refactor-v2.md` for implementing the new TrueTraceShorts strategy in code: V2 strategic models, hard gates, anti-AI-slop blockers, SeriesRegistry, 100-point scoring, hash-bound ReviewPackageV2, and deterministic five-candidate examples without rendering or platform side effects. See `references/truetrace-v2-operational-bridge.md` for the next implementation step: secure the Sprint-1 commit, add minimal side-effect-free bridge/workflow/CLI modules, deterministic ReviewPackageV2 JSON, strict approval parsing, calibrated scoring, anti-example/human-texture gates, manual claim/disclosure stubs, LinkedIn manual metadata, and Remotion-as-later-spike documentation without production expansion.

See `references/audio-caption-preview-pipeline.md` for the approval-first EdgeTTS + ASS subtitles + FFmpeg review-MP4 pipeline, including actual-audio-duration probing and the explicit `preserve_video` vs `trim_to_audio` duration-mode guardrail. See `references/pilot-preview-render-v2.md` for the stricter first-pilot preview pattern: preview-only approval state, EdgeTTS sentence SRT to preview word cues when forced alignment is unavailable, deterministic card/diagram animatic rendering, SHA256/duration verification, and Telegram media delivery while Wan/AI-image/platform calls remain blocked. See `references/static-active-word-captions-and-final-pilot-render.md` for the corrected Hormozi-style caption behaviour and the one-render final pilot review-package checklist.

See `references/final-motion-hybrid-rendering.md` for the final-near hybrid renderer pattern: ChatGPT Image/Codex or other AI tools provide text-free background/keyframe texture while deterministic renderer overlays own text, arrows, labels, captions, and final assembly. For quick prototypes, prefer ChatGPT Image/Codex backgrounds before local Qwen/ComfyUI unless privacy/cost/local-only constraints dominate. After a 1080 export, create a side-effect-free final approval package with media path, SHA256, platform metadata, social copy, quality notes, and approval commands before any delivery or platform step.

See `references/visual-first-final-render-repair.md` for the repaired final-render architecture after a rejected pilot: mark rejected renders as not-postable, avoid preview-board overlays, enforce `FinalOverlayPolicy`, validate ComfyUI/Wan output histories robustly, and use `APPROVED_FOR_VISUAL_PROOF` for one short visual proof before any second full final.

See `references/hybrid-scene-backgrounds-codex.md` for the concrete ChatGPT Image/Codex scene-background workflow: side-effect-free `SceneBackgroundPlan`, per-scene text-free backgrounds, renderer-owned overlays, and visual QA before review delivery. Prefer this over local Qwen/ComfyUI when the user allows Codex image generation and local GPU should be reserved for video/model work.

See `references/monetization-and-approval-notes.md` for current working notes captured from official YouTube/TikTok docs and the recommended approval/posting setup. See `references/retention-led-shorts.md` for retention scoring signals, no-faith/adjacent-pillar handling, and voiceover finalization rules. See `references/render-manifest-pipeline.md` for the script → shot plan → render manifest → renderer sequence that prevents generic AI slideshow output. See `references/manifest-preview-renderer.md` for the first deterministic Pillow/FFmpeg preview-renderer pattern. See `references/local-preview-frame-writer.md` for the staged `LocalPreviewPlan → PNG scene cards → FFmpeg concat plan` boundary before MP4 rendering. See `references/social-draft-approval-package.md` for the typed first-post approval package to build before TTS or platform APIs. See `references/content-automation-patterns.md` for public workflow patterns, queue/status/cost tracking, versioned approval, synthetic-content disclosure, originality gates, and the user's German-preview/English-final rule. See `references/ai-video-asset-planning.md` for motion-led final-render asset planning with Kling/Hailuo/Runway-style clips and screen simulations. See `references/imported-ai-clip-assembly.md` for hybrid assembly where manually generated provider clips are imported into a deterministic local renderer. See `references/nvidia-keyframe-generation.md` for NVIDIA-hosted keyframe generation, model fallback, secret handling, and pre-I2V logo/plate cleanup. Re-check official docs before making business-critical decisions because platform programs change often.

## Project/repo hygiene for automated video bots

- Keep source code in Git; keep generated videos, assets, `.env`, OAuth tokens, API keys, and downloaded secrets out of Git.
- Use `.gitignore` aggressively for outputs, media caches, local credentials, and temp files.
- Store platform credentials as runtime secrets; never print raw tokens.
- Build the repo as modular components: idea generation, scripting, rendering, review, publishing, analytics.
- For approval-first Retention Studio implementations, use a typed side-effect-free core before real I/O: `ContentBrief`, `ScriptDraft`, `VideoCandidate`, review batches, Telegram delivery plans, then shot plans. See `references/approval-first-retention-studio-core.md`.

## Sensitive-content positioning

When AI/productivity automation is the primary brand, be cautious about mixing in religious/FaithTok, health, finance, or other high-trust themes. If the user flags the combination as sensitive, pause that pillar rather than forcing it into the same testbatch. Replace it with a safer adjacent pillar (for example `attention_design`) and document the paused pillar in config so it can be revisited only with explicit approval and a separate brand strategy.

## Pitfalls

- Do not send several generated preview videos as unlabeled media attachments just because they are distinct files. Telegram/mobile previews can make timing-card MP4s look identical. Verify uniqueness (path + title + hash/duration) and provide labels or an indexed summary; if the user complains about duplicate-looking media, stop resending, verify artifacts, and continue the pipeline.
- Do not let final-near preview layers drift apart: if the top field, voiceover, and subtitles do not say the same thing, rebuild the render plan from one final script source before sending another clip.
- Do not make broad-audience AI-system shorts sound like architecture lectures. For early-AI users, convert abstract workflow concepts into concrete everyday failures first, then reveal the principle.
- Do not over-optimize renderer quality before testing whether the content format earns retention.
- Do not keep iterating a polished pilot when the user identifies the core failure as “pretty but semantically weak.” Archive it as a learning case, preserve what worked, and restart with a Proof Short whose first second shows a real visible fail and an immediately useful rule.
- Do not let German approval previews become unreadable technical cards: use pixel-width-aware wrapping, readable fonts, limited line counts, visual checks for clipped/overcrowded text, and hard mobile safe-zone tests for captions/highlights before delivery.
- Do not confuse approval language with final audience language. For this user, approval previews are German by default; final social videos remain English unless explicitly changed.
- Do not copy public automation workflows wholesale: keep their queue/status/cost tracking and template structure, but remove blind auto-publishing until a human approval/version lock exists.
- Do not mass-generate repetitive shorts without originality/inauthentic-content gates; this can harm monetization/account health and create AI slop.
- Do not jump directly from a script idea to ComfyUI/Wan/Qwen/Image/video generation. First build and inspect the content-to-review spine so weak hooks, generic scripts, policy issues, and static visual concepts are killed before render time.
- Do not assume local Qwen/ComfyUI is the best first image step for every final-near short. If the user permits ChatGPT Image/Codex, use it for text-free scene backgrounds/keyframes when the deterministic renderer owns labels/captions and local GPU should be saved for video/model work. When provider quality matters, verify the actual `image_gen.provider`/model from config and the image cache filenames/metadata rather than guessing; for premium styleframes prefer the highest available reasonable GPT Image tier, but keep fake invoice/email/UI/payment-route mockups deterministic.
- Do not create a parallel candidate path for content experiments; convert content artifacts into the existing candidate factory input so IDs, hashtags, duration classes, quality gates, and pillar/platform validation stay centralized.
- Do not use `x or default` for optional content/script overrides in factories; explicit blank strings and zero durations must pass through to validation so tests can catch them.
- Do not assume TikTok monetization rules match YouTube: TikTok Creator Rewards emphasizes 1min+ eligible videos, while shorter videos may still be better for growth.
- Do not treat a keyframe animatic as a successful Wan/I2V proof. If ComfyUI/Wan I2V fails or history contains no valid video output, label any fallback MP4 as `not_wan` / composition-review-only and block full-final progression.
- Do not treat deterministic layout proofs as the final premium visual style when the user asked for a high-end social-video look. Layouts are for mechanism composition; after approval, use a tightly scoped premium AI styleframe pass with strict negative prompts and verified delivery before endframes/Wan.
- Do not mistake premium abstract glassmorphism for an approved final direction if the user wants a credible everyday work scene. When the user says the motif is still too abstract, switch to real-life work-scene styleframes with laptop/tablet/meeting-room/project-table anchors; keep motion graphics subtle and renderer-owned.
- If the user rejects the production method itself, do not keep iterating content visuals. Reject/audit the proof, run a technical I2V sanity test with a trivial non-content image, and only after it passes generate a small set of styleframes for direction review; see `references/visual-production-reset-i2v-first.md`.
- Do not keep trying blind text-to-video after the user rejects a proof as abstract, dark, generic, or semantically ungrounded. Move to keyframe-first planning, review the keyframes, then run at most one short I2V/first-last-frame proof.
- Do not publish religious or health-adjacent content with overconfident claims; preserve humility, nuance, and platform safety.
- Do not rely on platform payout as the only monetization path; plan indirect monetization such as newsletter, templates, affiliates, digital products, or services.

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


test-driven-development

---
name: test-driven-development
description: "TDD: enforce RED-GREEN-REFACTOR, tests before code."
version: 1.1.0
author: Hermes Agent (adapted from obra/superpowers)
license: MIT
platforms: [linux, macos, windows]
metadata:
  hermes:
    tags: [testing, tdd, development, quality, red-green-refactor]
    related_skills: [systematic-debugging, writing-plans, subagent-driven-development]
---

# Test-Driven Development (TDD)

## Overview

Write the test first. Watch it fail. Write minimal code to pass.

**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.

**Violating the letter of the rules is violating the spirit of the rules.**

## When to Use

**Always:**
- New features
- Bug fixes
- Refactoring
- Behavior changes

**Exceptions (ask the user first):**
- Throwaway prototypes
- Generated code
- Configuration files

Thinking "skip TDD just this once"? Stop. That's rationalization.

## The Iron Law

```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```

Write code before the test? Delete it. Start over.

**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete

Implement fresh from tests. Period.

## Red-Green-Refactor Cycle

### RED — Write Failing Test

Write one minimal test showing what should happen.

**Good test:**
```python
def test_retries_failed_operations_3_times():
    attempts = 0
    def operation():
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise Exception('fail')
        return 'success'

    result = retry_operation(operation)

    assert result == 'success'
    assert attempts == 3
```
Clear name, tests real behavior, one thing.

**Bad test:**
```python
def test_retry_works():
    mock = MagicMock()
    mock.side_effect = [Exception(), Exception(), 'success']
    result = retry_operation(mock)
    assert result == 'success'  # What about retry count? Timing?
```
Vague name, tests mock not real code.

**Requirements:**
- One behavior per test
- Clear descriptive name ("and" in name? Split it)
- Real code, not mocks (unless truly unavoidable)
- Name describes behavior, not implementation

### Verify RED — Watch It Fail

**MANDATORY. Never skip.**

```bash
# Use terminal tool to run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
```

Confirm:
- Test fails (not errors from typos)
- Failure message is expected
- Fails because the feature is missing

**Test passes immediately?** You're testing existing behavior. Fix the test.

**Test errors?** Fix the error, re-run until it fails correctly.

### GREEN — Minimal Code

Write the simplest code to pass the test. Nothing more.

**Good:**
```python
def add(a, b):
    return a + b  # Nothing extra
```

**Bad:**
```python
def add(a, b):
    result = a + b
    logging.info(f"Adding {a} + {b} = {result}")  # Extra!
    return result
```

Don't add features, refactor other code, or "improve" beyond the test.

**Cheating is OK in GREEN:**
- Hardcode return values
- Copy-paste
- Duplicate code
- Skip edge cases

We'll fix it in REFACTOR.

### Verify GREEN — Watch It Pass

**MANDATORY.**

```bash
# Run the specific test
pytest tests/test_feature.py::test_specific_behavior -v

# Then run ALL tests to check for regressions
pytest tests/ -q
```

Confirm:
- Test passes
- Other tests still pass
- Output pristine (no errors, warnings)

**Test fails?** Fix the code, not the test.

**Other tests fail?** Fix regressions now.

### REFACTOR — Clean Up

After green only:
- Remove duplication
- Improve names
- Extract helpers
- Simplify expressions

Keep tests green throughout. Don't add behavior.

**If tests fail during refactor:** Undo immediately. Take smaller steps.

### Repeat

Next failing test for next behavior. One cycle at a time.

## Why Order Matters

**"I'll write tests after to verify it works"**

Tests written after code pass immediately. Passing immediately proves nothing:
- Might test the wrong thing
- Might test implementation, not behavior
- Might miss edge cases you forgot
- You never saw it catch the bug

Test-first forces you to see the test fail, proving it actually tests something.

**"I already manually tested all the edge cases"**

Manual testing is ad-hoc. You think you tested everything but:
- No record of what you tested
- Can't re-run when code changes
- Easy to forget cases under pressure
- "It worked when I tried it" ≠ comprehensive

Automated tests are systematic. They run the same way every time.

**"Deleting X hours of work is wasteful"**

Sunk cost fallacy. The time is already gone. Your choice now:
- Delete and rewrite with TDD (high confidence)
- Keep it and add tests after (low confidence, likely bugs)

The "waste" is keeping code you can't trust.

**"TDD is dogmatic, being pragmatic means adapting"**

TDD IS pragmatic:
- Finds bugs before commit (faster than debugging after)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Enables refactoring (change freely, tests catch breaks)

"Pragmatic" shortcuts = debugging in production = slower.

**"Tests after achieve the same goals — it's spirit not ritual"**

No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"

Tests-after are biased by your implementation. You test what you built, not what's required. Tests-first force edge case discovery before implementing.

## Common Rationalizations

| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to the test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
| "Existing code has no tests" | You're improving it. Add tests for the code you touch. |

## Red Flags — STOP and Start Over

If you catch yourself doing any of these, delete the code and restart with TDD:

- Code before test
- Test after implementation
- Test passes immediately on first run
- Can't explain why test failed
- Tests added "later"
- Rationalizing "just this once"
- "I already manually tested it"
- "Tests after achieve the same purpose"
- "Keep as reference" or "adapt existing code"
- "Already spent X hours, deleting is wasteful"
- "TDD is dogmatic, I'm being pragmatic"
- "This is different because..."

**All of these mean: Delete code. Start over with TDD.**

## Verification Checklist

Before marking work complete:

- [ ] Every new function/method has a test
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases and errors covered

Can't check all boxes? You skipped TDD. Start over.

## When Stuck

| Problem | Solution |
|---------|----------|
| Don't know how to test | Write the wished-for API. Write the assertion first. Ask the user. |
| Test too complicated | Design too complicated. Simplify the interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers. Still complex? Simplify the design. |

## Hermes Agent Integration

### Running Tests

Use the `terminal` tool to run tests at each step:

```python
# RED — verify failure
terminal("pytest tests/test_feature.py::test_name -v")

# GREEN — verify pass
terminal("pytest tests/test_feature.py::test_name -v")

# Full suite — verify no regressions
terminal("pytest tests/ -q")
```

### CLI Entry Points

For new `python -m package.cli.command` style entry points, keep the TDD loop boring and side-effect free:

1. Write a test that imports `main` from the missing CLI module and calls `main([])`.
2. Capture stdout/stderr with the project test framework (`capsys` in pytest) or inject a `stdout` file-like object if supported.
3. RED should usually be an import failure for the missing CLI module/package, not a vague end-to-end shell failure.
4. Implement `main(argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int` so tests can call it without spawning a subprocess.
5. Add the `if __name__ == "__main__": raise SystemExit(main())` guard only after the callable path is green.
6. Verify both the unit test and the real module invocation, e.g. `python -m package.cli.command`, before committing.

This catches command wiring while avoiding accidental network calls, publishing actions, or other theatrical nonsense masquerading as a CLI test.

### With delegate_task

When dispatching subagents for implementation, enforce TDD in the goal:

```python
delegate_task(
    goal="Implement [feature] using strict TDD",
    context="""
    Follow test-driven-development skill:
    1. Write failing test FIRST
    2. Run test to verify it fails
    3. Write minimal code to pass
    4. Run test to verify it passes
    5. Refactor if needed
    6. Commit

    Project test command: pytest tests/ -q
    Project structure: [describe relevant files]
    """,
    toolsets=['terminal', 'file']
)
```

### With systematic-debugging

Bug found? Write failing test reproducing it. Follow TDD cycle. The test proves the fix and prevents regression.

Never fix bugs without a test.

## Demo / Fixture Features

When adding a deterministic demo fixture for later integration wiring, still use RED-GREEN-REFACTOR. Write the wished-for import/API in the test first, verify the missing module/API failure, then implement the smallest fixture that proves the real workflow path. Assert stable IDs/order, bucket counts/classifications, and safety invariants such as review text not implying publication. See `references/deterministic-demo-fixtures.md` for the concise pattern.

## Side-Effect-Free Integration Boundaries

When building toward an external integration (Telegram, email, publishing APIs, render farms, payment flows, etc.), add a tested preparation boundary before any real I/O:

1. Write the wished-for pure function in the test first, e.g. `prepare_review_delivery(text) -> DeliveryPlan`.
2. RED should be a missing module/API or missing validation failure, not a real network/send failure.
3. The GREEN implementation should return a typed/dataclass plan object with explicit metadata: destination/topic, delivery type, human-approval requirement, and `side_effects=()`.
4. Validate safety constraints at the boundary: non-empty content, platform size limits, no forbidden action language, no implicit approval, no credentials or generated media unless explicitly expected.
5. Add a demo CLI only after the pure boundary is green. The CLI should print metadata first, then payload, and still perform no external I/O.
6. Verify both the unit tests and a real `python -m package.cli.demo_*` invocation before committing.

This pattern lets later sending/rendering/publishing adapters consume a boring, audited object instead of smuggling side effects into formatting code. Boring is how we avoid building a content catapult with a moustache.

### Media / Rendering Pipeline Boundaries

When building toward image/video generation or renderer integration, add deterministic planning artifacts before any GPU/API work:

1. Write tests for the wished-for pure API first, e.g. `create_shot_plan(script)`, `build_render_manifest(plan)`, `build_local_preview_plan(manifest)`, or `build_ffmpeg_concat_plan(plan, frames, output_path)`.
2. RED should be a missing module/API or missing validation behavior — never a failed ComfyUI/Wan/Qwen/API invocation.
3. Keep each stage inspectable and side-effect-free until the explicit adapter layer: `ScriptDraft -> ShotPlan -> RenderManifest -> PreviewPlan/PreviewArtifact -> renderer adapter`.
4. Assert stable IDs/order, scene counts, timing continuity, vertical settings (`1080x1920`, `30fps` when building Shorts/Reels/TikTok), reduced preview settings (`540x960`, `24fps` when building fast local timing previews), and placeholder/runtime paths before generating media.
5. Add guardrail tests that reject static slideshows, baked-in/generated text inside assets, non-renderer-owned captions/labels, unsafe render settings, missing fields, bad timings, non-PNG frame paths, non-MP4 output paths, and mismatched frame counts.
6. Split artifact-writing/rendering into explicit boundaries after the pure plans are green: frame writer returns `side_effects=("write_png",)`, concat planning remains side-effect-free, and the FFmpeg renderer returns `side_effects=("write_concat", "call_ffmpeg", "write_mp4")` plus `external_calls=("ffmpeg",)`.
7. Only after the manifest/preview boundary is green should a later adapter call local GPU tools or external services. Planning first; GPU roulette later.

This prevents wasting render time on unclear direction and keeps expensive media generation behind a small, audited boundary.

### Approval-First Media/Social Pipelines

When a feature crosses from content planning toward media/social delivery, prefer many small RED-GREEN-README-COMMIT slices instead of one large integration jump:

1. typed artifact/model (`SocialMediaDraft`, `RenderManifest`, etc.)
2. renderer/formatter for human review text
3. side-effect-free delivery/preparation boundary
4. demo CLI that prints metadata first, then payload

For each slice, write the missing import/API test first, verify the expected import/API failure, implement only that slice, run the focused test, run the full suite, update docs, and commit. Keep invariant tests explicit: `requires_human_approval=True`, `side_effects=()`, no external calls, no credential access, no file writes unless that adapter is the explicit feature, and no accidental action language such as `publish`/`post` in review/delivery text.

This staged approach keeps approval semantics auditable and prevents a later renderer, Telegram sender, or platform adapter from being smuggled into formatting code.

## Testing Anti-Patterns

- **Testing mock behavior instead of real behavior** — mocks should verify interactions, not replace the system under test
- **Testing implementation details** — test behavior/results, not internal method calls
- **Happy path only** — always test edge cases, errors, and boundaries
- **Brittle tests** — tests should verify behavior, not structure; refactoring shouldn't break them
- **Substring sentinels that collide with generated IDs** — when asserting that amounts or fixture values are excluded from a result, assert the full formatted token (e.g. `999.00`) or a typed field, not a short substring like `999` that can appear inside UUIDs/hash-like IDs.
- **Domain classes named `Test*` trigger pytest collection warnings** — if a production dataclass/model must be named like `TestBatch`, set `__test__ = False` on the class or choose a non-`Test` prefix. Keep the fix in production code when the name is part of the domain language, and verify the warning is gone before committing.
- **Defaulting with `x or default` hides invalid explicit inputs** — when a factory/test helper accepts optional overrides, use `x if x is not None else default` so tests can pass intentionally blank strings (`""`) or zero values and watch validation reject them. Otherwise invalid-input tests may silently receive defaults and give false confidence.

## Final Rule

```
Production code → test exists and failed first
Otherwise → not TDD
```

No exceptions without the user's explicit permission.


todo

Git status/tests prüfen und Sprint-3.1 Commit sichern

Preview v2 als approved_proof_short markieren

manual_upload_v1 MP4 technisch finalisieren und Metadaten erzeugen

Final QA + Claim/AI Disclosure Gate dokumentieren

Manual Posting Pack + Analytics Templates erzeugen

MP4, Pack, Analytics, QA Summary per Telegram senden/verifizieren


terminal

git status --short && git log -1 --oneline && pytest tests/test_caption_safe_zone.py tests/test_static_caption_segments.py tests/test_image_generation_policy.py tests/test_proof_short_mode.py tests/test_pilot_production_v2.py -q


Output:
cf66672 fix: enforce caption safe zones and image policy
........................                                                 [100%]
24 passed in 0.41s


from hermes_tools import terminal
cmd = r'''
set -euo pipefail
BASE=/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s
PREVIEW=$BASE/preview_v2_caption_safe/digital-red-flags-fake-invoice-payment-route-30s_preview_v2_caption_safe.mp4
OUT=$BASE/manual_upload_v1
mkdir -p "$OUT/qa_frames"
FINAL=$OUT/digital-red-flags-fake-invoice-payment-route-30s_manual_upload_v1.mp4
ffmpeg -y -i "$PREVIEW" \
  -vf "fps=24,scale=1080:1920:flags=lanczos,format=yuv420p" \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11" \
  -c:v libx264 -preset slow -crf 18 -profile:v high -level 4.1 \
  -c:a aac -b:a 128k -ar 48000 -movflags +faststart -metadata title="Digital Red Flags - Fake Invoice Payment Route" \
  "$FINAL"
ffprobe -v error -show_entries format=duration:stream=index,codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,sample_rate,channels -of json "$FINAL" > "$OUT/ffprobe_manual_upload_v1.json"
sha256sum "$FINAL" > "$OUT/sha256_manual_upload_v1.txt"
# Extract QA frames and contact sheet from final MP4
for t in 1 4 10 15.8 17.8 20.2 24.5 28.2 30.2; do
  safe=${t//./_}
  ffmpeg -y -ss "$t" -i "$FINAL" -frames:v 1 -q:v 2 "$OUT/qa_frames/frame_${safe}s.jpg" >/dev/null 2>&1
done
python - <<'PY'
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
OUT=Path('/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/manual_upload_v1')
frames=[(1,'1'),(4,'4'),(10,'10'),(15.8,'15_8'),(17.8,'17_8'),(20.2,'20_2'),(24.5,'24_5'),(28.2,'28_2'),(30.2,'30_2')]
font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',24)
sheet=Image.new('RGB',(810,1440),(12,16,24)); d=ImageDraw.Draw(sheet)
for idx,(ts,safe) in enumerate(frames):
    p=OUT/'qa_frames'/f'frame_{safe}s.jpg'
    im=Image.open(p).convert('RGB').resize((270,480))
    x=(idx%3)*270; y=(idx//3)*480
    sheet.paste(im,(x,y)); d.rectangle((x,y,x+269,y+479),outline=(80,90,115),width=2)
    d.text((x+12,y+12),f'{ts:.1f}s',font=font,fill=(247,202,76))
sheet.save(OUT/'manual_upload_v1_qa_contact_sheet.jpg', quality=92)
PY
'''
print(terminal(cmd, timeout=600, workdir='/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot')['output'])


Output:
ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers
  built with gcc 13 (Ubuntu 13.2.0-23ubuntu3)
  configuration: --prefix=/usr --extra-version=3ubuntu5 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --disable-omx --enable-gnutls --enable-libaom --enable-libass --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libglslang --enable-libgme --enable-libgsm --enable-libharfbuzz --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-openal --enable-opencl --enable-opengl --disable-sndio --enable-libvpl --disable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-ladspa --enable-libbluray --enable-libjack --enable-libpulse --enable-librabbitmq --enable-librist --enable-libsrt --enable-libssh --enable-libsvtav1 --enable-libx264 --enable-libzmq --enable-libzvbi --enable-lv2 --enable-sdl2 --enable-libplacebo --enable-librav1e --enable-pocketsphinx --enable-librsvg --enable-libjxl --enable-shared
  libavutil      58. 29.100 / 58. 29.100
  libavcodec     60. 31.102 / 60. 31.102
  libavformat    60. 16.100 / 60. 16.100
  libavdevice    60.  3.100 / 60.  3.100
  libavfilter     9. 12.100 /  9. 12.100
  libswscale      7.  5.100 /  7.  5.100
  libswresample   4. 12.100 /  4. 12.100
  libpostproc    57.  3.100 / 57.  3.100
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v2_caption_safe/digital-red-flags-fake-invoice-payment-route-30s_preview_v2_caption_safe.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Duration: 00:00:31.32, start: 0.000000, bitrate: 247 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, 179 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[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 24000 Hz, mono, fltp, 63 kb/s (default)
    Metadata:
      handler_name    : SoundHandler
      vendor_id       : [0][0][0][0]
Stream mapping:
  Stream #0:0 -> #0:0 (h264 (native) -> h264 (libx264))
  Stream #0:1 -> #0:1 (aac (native) -> aac (native))
Press [q] to stop, [?] for help
[libx264 @ 0x59451a96dc40] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512
[libx264 @ 0x59451a96dc40] profile High, level 4.1, 4:2:0, 8-bit
[libx264 @ 0x59451a96dc40] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=4 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=8 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=2 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=3 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=24 scenecut=40 intra_refresh=0 rc_lookahead=50 rc=crf mbtree=1 crf=18.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_sprint30/digital-red-flags-fake-invoice-payment-route-30s/manual_upload_v1/digital-red-flags-fake-invoice-payment-route-30s_manual_upload_v1.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    title           : Digital Red Flags - Fake Invoice Payment Route
    encoder         : Lavf60.16.100
  Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 1080x1920, q=2-31, 24 fps, 12288 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
  Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, mono, fltp, 128 kb/s (default)
    Metadata:
      handler_name    : SoundHandler
      vendor_id       : [0][0][0][0]
      encoder         : Lavc60.31.102 aac
frame=    2 fps=0.0 q=23.0 size=       0kB time=00:00:00.02 bitrate=  18.0kbits/s speed=0.0414x
frame=   50 fps= 49 q=23.0 size=       0kB time=00:00:02.02 bitrate=   0.2kbits/s speed=1.98x
frame=  101 fps= 66 q=23.0 size=       0kB time=00:00:04.11 bitrate=   0.1kbits/s speed=2.69x
frame=  160 fps= 79 q=23.0 size=       0kB time=00:00:06.54 bitrate=   0.1kbits/s speed=3.21x
frame=  217 fps= 85 q=23.0 size=     256kB time=00:00:08.95 bitrate= 234.1kbits/s speed=3.53x
frame=  269 fps= 88 q=23.0 size=     256kB time=00:00:11.11 bitrate= 188.7kbits/s speed=3.66x
frame=  329 fps= 93 q=23.0 size=     256kB time=00:00:13.63 bitrate= 153.9kbits/s speed=3.85x
frame=  387 fps= 96 q=23.0 size=     512kB time=00:00:16.02 bitrate= 261.8kbits/s speed=3.96x
frame=  449 fps= 99 q=23.0 size=     512kB time=00:00:18.62 bitrate= 225.2kbits/s speed=4.09x
frame=  501 fps= 99 q=23.0 size=     768kB time=00:00:20.82 bitrate= 302.2kbits/s speed=4.12x
frame=  562 fps=101 q=23.0 size=     768kB time=00:00:23.31 bitrate= 269.8kbits/s speed= 4.2x
frame=  624 fps=103 q=23.0 size=     768kB time=00:00:25.92 bitrate= 242.7kbits/s speed=4.28x
frame=  670 fps=102 q=23.0 size=    1024kB time=00:00:27.81 bitrate= 301.6kbits/s speed=4.24x
[mp4 @ 0x59451a924700] Starting second pass: moving the moov atom to the beginning of the file
[out#0/mp4 @ 0x59451a8d35c0] video:730kB audio:410kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 1.992877%
frame=  751 fps=107 q=-1.0 Lsize=    1163kB time=00:00:31.37 bitrate= 303.5kbits/s speed=4.45x
[libx264 @ 0x59451a96dc40] frame I:4     Avg QP: 8.99  size: 52335
[libx264 @ 0x59451a96dc40] frame P:189   Avg QP:15.20  size:  2395
[libx264 @ 0x59451a96dc40] frame B:558   Avg QP:23.81  size:   152
[libx264 @ 0x59451a96dc40] consecutive B-frames:  0.9%  0.0%  0.0% 99.1%
[libx264 @ 0x59451a96dc40] mb I  I16..4: 82.3%  4.6% 13.1%
[libx264 @ 0x59451a96dc40] mb P  I16..4:  0.5%  0.0%  0.3%  P16..4:  0.5%  0.1%  0.1%  0.0%  0.0%    skip:98.5%
[libx264 @ 0x59451a96dc40] mb B  I16..4:  0.0%  0.0%  0.0%  B16..8:  1.1%  0.0%  0.0%  direct: 0.0%  skip:98.8%  L0:48.0% L1:51.8% BI: 0.1%
[libx264 @ 0x59451a96dc40] 8x8 transform intra:4.5% inter:15.1%
[libx264 @ 0x59451a96dc40] direct mvs  spatial:99.1% temporal:0.9%
[libx264 @ 0x59451a96dc40] coded y,uvDC,uvAC intra: 14.0% 13.7% 10.0% inter: 0.1% 0.1% 0.1%
[libx264 @ 0x59451a96dc40] i16 v,h,dc,p: 30% 70%  0%  0%
[libx264 @ 0x59451a96dc40] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 43% 18% 39%  0%  0%  0%  0%  0%  0%
[libx264 @ 0x59451a96dc40] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 31% 38% 10%  3%  3%  4%  3%  3%  4%
[libx264 @ 0x59451a96dc40] i8c dc,h,v,p: 75% 15%  9%  1%
[libx264 @ 0x59451a96dc40] Weighted P-Frames: Y:0.0% UV:0.0%
[libx264 @ 0x59451a96dc40] ref P L0: 73.5%  7.8% 13.1%  5.6%
[libx264 @ 0x59451a96dc40] ref B L0: 57.4% 41.9%  0.8%
[libx264 @ 0x59451a96dc40] ref B L1: 96.4%  3.6%
[libx264 @ 0x59451a96dc40] kb/s:190.93
[aac @ 0x59451a9908c0] Qavg: 17214.582


Explanation:
**Creating status and packs**

I need to create a status and packs. It looks like I’m dealing with 751 frames with a duration of 31.37. I should probably check that using ffprobe to confirm the duration is accurate. It seems all good so far, but I need the status approved. I also need to make sure to use the Vision QA contact for any additional support or verification. Alright, let’s keep moving forward with this!

Question: Final QA contact sheet for a 9:16 manual upload video. Check if captions are clipped/off safe area, if obvious logos/real bank data/QR codes/people appear, and whether hook/takeaway are visually clear.


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

Question: Final QA contact sheet for a 9:16 manual upload video. Check if captions are clipped/off safe area, if obvious logos/real bank data/QR codes/people appear, and whether hook/takeaway are visually clear.


Explanation:
**Evaluating mockup changes**

I'm considering a fictitious mockup where "example-company.com" serves as a generic placeholder. But using .com may not be ideal, so I'm thinking of changing it to something like billing@sample.test. That doesn't change the story much, though I need to ensure the final upload doesn’t look real.

I might need to adjust the rendering source script and re-run the manual upload with my modifications. Maybe I should regenerate the preview to reflect these changes and ensure it stays generic. Let's aim for a safe re-render using .test.


python /tmp/autoshorts_proof_short_sprint30/render_fake_invoice_preview_v2.py >/tmp/render_v2_final_rerun.log && tail -60 /tmp/render_v2_final_rerun.log
BASE=/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s
PREVIEW=$BASE/preview_v2_caption_safe/digital-red-flags-fake-invoice-payment-route-30s_preview_v2_caption_safe.mp4
OUT=$BASE/manual_upload_v1
FINAL=$OUT/digital-red-flags-fake-invoice-payment-route-30s_manual_upload_v1.mp4
ffmpeg -y -i "$PREVIEW" -vf "fps=24,scale=1080:1920:flags=lanczos,format=yuv420p" -af "loudnorm=I=-16:TP=-1.5:LRA=11" -c:v libx264 -preset slow -crf 18 -profile:v high -level 4.1 -c:a aac -b:a 128k -ar 48000 -movflags +faststart -metadata title="Digital Red Flags - Fake Invoice Payment Route" "$FINAL" >/tmp/manual_encode.log 2>&1
ffprobe -v error -show_entries format=duration:stream=index,codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,sample_rate,channels -of json "$FINAL" > "$OUT/ffprobe_manual_upload_v1.json"
sha256sum "$FINAL" > "$OUT/sha256_manual_upload_v1.txt"
rm -f "$OUT/qa_frames"/*.jpg
for t in 1 4 10 15.8 17.8 20.2 24.5 28.2 30.2; do safe=${t//./_}; ffmpeg -y -ss "$t" -i "$FINAL" -frames:v 1 -q:v 2 "$OUT/qa_frames/frame_${safe}s.jpg" >/dev/null 2>&1; done
python - <<'PY'
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
OUT=Path('/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/manual_upload_v1')
frames=[(1,'1'),(4,'4'),(10,'10'),(15.8,'15_8'),(17.8,'17_8'),(20.2,'20_2'),(24.5,'24_5'),(28.2,'28_2'),(30.2,'30_2')]
font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',24)
sheet=Image.new('RGB',(810,1440),(12,16,24)); d=ImageDraw.Draw(sheet)
for idx,(ts,safe) in enumerate(frames):
    p=OUT/'qa_frames'/f'frame_{safe}s.jpg'
    im=Image.open(p).convert('RGB').resize((270,480))
    x=(idx%3)*270; y=(idx//3)*480
    sheet.paste(im,(x,y)); d.rectangle((x,y,x+269,y+479),outline=(80,90,115),width=2)
    d.text((x+12,y+12),f'{ts:.1f}s',font=font,fill=(247,202,76))
sheet.save(OUT/'manual_upload_v1_qa_contact_sheet.jpg', quality=92)
PY
cat "$OUT/sha256_manual_upload_v1.txt" && cat "$OUT/ffprobe_manual_upload_v1.json"