"""Procedural motion renderer for final-near TrueTrace review videos."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import math
import subprocess

from PIL import Image, ImageDraw, ImageFont

from autoshorts.ideas.candidate import ValidationResult
from autoshorts.rendering.final_video_plan import FinalVideoPlan, FinalVideoScene, validate_final_video_plan

Runner = Callable[..., subprocess.CompletedProcess[str]]

@dataclass(frozen=True)
class MotionRenderPlan:
    final_plan: FinalVideoPlan
    output_dir: str
    frame_dir: str
    concat_path: str
    output_path: str
    width: int
    height: int
    fps: int
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

@dataclass(frozen=True)
class MotionRenderResult:
    output_path: str
    frame_paths: tuple[str, ...]
    concat_path: str
    returncode: int
    stdout: str
    stderr: str
    side_effects: tuple[str, ...] = ("write_png", "write_concat", "call_ffmpeg", "write_mp4")
    external_calls: tuple[str, ...] = ("ffmpeg",)

def build_motion_render_plan(final_plan: FinalVideoPlan, *, output_dir: str | Path, preview_scale: float = 1.0) -> MotionRenderPlan:
    validation = validate_final_video_plan(final_plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    if not (0.25 <= preview_scale <= 1.0):
        raise ValueError("preview_scale must be between 0.25 and 1.0")
    root = Path(output_dir)
    width = int(final_plan.width * preview_scale)
    height = int(final_plan.height * preview_scale)
    fps = 24 if preview_scale < 1 else final_plan.fps
    plan = MotionRenderPlan(
        final_plan=final_plan,
        output_dir=str(root),
        frame_dir=str(root / "frames"),
        concat_path=str(root / "frames.txt"),
        output_path=str(root / "motion-preview.mp4"),
        width=width,
        height=height,
        fps=fps,
    )
    validation = validate_motion_render_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    return plan

def validate_motion_render_plan(plan: MotionRenderPlan) -> ValidationResult:
    errors: list[str] = []
    if plan.width <= 0 or plan.height <= 0:
        errors.append("width and height must be positive")
    if plan.width * 16 != plan.height * 9:
        errors.append("motion render must keep 9:16 vertical aspect ratio")
    if plan.fps not in {24, 30}:
        errors.append("fps must be 24 or 30")
    if not plan.output_path.endswith(".mp4"):
        errors.append("output_path must end with .mp4")
    if not plan.concat_path.endswith(".txt"):
        errors.append("concat_path must end with .txt")
    if not plan.frame_dir.strip():
        errors.append("frame_dir is required")
    if plan.side_effects:
        errors.append("motion render plan must not have side effects")
    if plan.external_calls:
        errors.append("motion render plan must not have external calls")
    final_validation = validate_final_video_plan(plan.final_plan)
    errors.extend(final_validation.errors)
    return ValidationResult(is_valid=not errors, errors=tuple(errors))

def _font(size: int, *, bold: bool = False):
    names = ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf") if bold else ("DejaVuSans.ttf",)
    for name in names:
        try:
            return ImageFont.truetype(name, size=size)
        except OSError:
            continue
    return ImageFont.load_default()

def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
    lines: list[str] = []
    for paragraph in text.splitlines():
        words = paragraph.split()
        if not words:
            lines.append("")
            continue
        current = words[0]
        for word in words[1:]:
            candidate = f"{current} {word}"
            if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width:
                current = candidate
            else:
                lines.append(current)
                current = word
        lines.append(current)
    return lines

def _draw_text_block(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, *, font, fill, max_width: int, spacing: int = 10) -> int:
    x, y = xy
    line_height = getattr(font, "size", 24) + spacing
    for line in _wrap(draw, text, font, max_width):
        draw.text((x, y), line, font=font, fill=fill)
        y += line_height
    return y

def _ease(progress: float) -> float:
    return 0.5 - 0.5 * math.cos(math.pi * max(0.0, min(1.0, progress)))

def _draw_email_card(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], *, title: str, body: str, accent: tuple[int, int, int], font_scale: int) -> None:
    x1, y1, x2, y2 = box
    draw.rounded_rectangle(box, radius=24, fill=(248, 250, 252), outline=accent, width=4)
    draw.rectangle((x1, y1, x2, y1 + 16), fill=accent)
    small = _font(font_scale, bold=True)
    body_font = _font(max(16, int(font_scale * 0.78)))
    draw.text((x1 + 28, y1 + 32), title, font=small, fill=(15, 23, 42))
    _draw_text_block(draw, (x1 + 28, y1 + 86), body, font=body_font, fill=(51, 65, 85), max_width=x2 - x1 - 56, spacing=8)

def _render_scene(plan: MotionRenderPlan, scene: FinalVideoScene, progress: float) -> Image.Image:
    w, h = plan.width, plan.height
    image = Image.new("RGB", (w, h), (6, 12, 24))
    draw = ImageDraw.Draw(image)
    accent = (34, 211, 238)
    green = (52, 211, 153)
    amber = (251, 191, 36)
    red = (248, 113, 113)
    margin = max(28, w // 14)
    title_font = _font(max(34, w // 13), bold=True)
    mid_font = _font(max(24, w // 20), bold=True)
    small_font = _font(max(18, w // 27))

    # Ambient grid/gradient dots.
    for i in range(0, h, max(48, h // 18)):
        color = (10 + (i // 20) % 24, 24, 44)
        draw.line((0, i, w, i), fill=color, width=1)
    draw.rectangle((0, 0, w, 18), fill=accent)

    p = _ease(progress)
    if scene.visual_mode == "inbox_motion":
        offset = int((1 - p) * -w * 0.25)
        _draw_text_block(draw, (margin, margin + offset // 5), scene.renderer_text, font=title_font, fill=(248, 250, 252), max_width=w - 2 * margin, spacing=12)
        card_y = int(h * 0.38)
        _draw_email_card(draw, (margin, card_y, w - margin, card_y + int(h * 0.24)), title="Demo inbox", body="All fields present\nFormat clean\nAction obvious", accent=green, font_scale=max(22, w // 24))
        draw.rounded_rectangle((margin, int(h * 0.72), w - margin, int(h * 0.79)), radius=22, fill=(22, 101, 52))
        draw.text((margin + 28, int(h * 0.735)), "Status: works in the demo", font=mid_font, fill=(220, 252, 231))
    elif scene.visual_mode == "email_card_interrupt":
        _draw_text_block(draw, (margin, margin), "Then one weird email arrives.", font=title_font, fill=(248, 250, 252), max_width=w - 2 * margin, spacing=12)
        shake = int(math.sin(progress * math.pi * 8) * 6 * (1 - progress))
        y = int(h * (0.32 - 0.05 * (1 - p)))
        _draw_email_card(draw, (margin + shake, y, w - margin + shake, y + int(h * 0.34)), title="New email", body='"hey can you handle this maybe??"\n\nMissing name\nWrong format\nUnclear request', accent=amber, font_scale=max(22, w // 23))
        pulse = int(80 + 80 * abs(math.sin(progress * math.pi * 4)))
        draw.ellipse((w - margin - 52, y + 28, w - margin - 16, y + 64), fill=(pulse, 65, 0))
    elif scene.visual_mode == "wrong_route_warning":
        _draw_text_block(draw, (margin, margin), "AI routes it confidently…", font=title_font, fill=(248, 250, 252), max_width=w - 2 * margin, spacing=12)
        cy = int(h * 0.48)
        x1, x2 = margin, w - margin
        draw.rounded_rectangle((x1, cy - 52, x1 + 150, cy + 52), radius=26, fill=(15, 118, 110), outline=green, width=3)
        draw.text((x1 + 38, cy - 18), "AI", font=mid_font, fill=(240, 253, 250))
        arrow_end = int(x1 + 180 + (x2 - x1 - 360) * p)
        draw.line((x1 + 170, cy, arrow_end, cy), fill=green, width=10)
        draw.polygon([(arrow_end, cy), (arrow_end - 24, cy - 18), (arrow_end - 24, cy + 18)], fill=green)
        draw.rounded_rectangle((x2 - 180, cy - 60, x2, cy + 60), radius=22, fill=(127, 29, 29), outline=red, width=4)
        draw.text((x2 - 150, cy - 18), "Wrong", font=mid_font, fill=(254, 226, 226))
        if progress > 0.55:
            draw.rounded_rectangle((margin, int(h * 0.70), w - margin, int(h * 0.80)), radius=24, fill=(69, 10, 10), outline=red, width=4)
            draw.text((margin + 26, int(h * 0.73)), "Not an AI problem. A workflow problem.", font=small_font, fill=(254, 226, 226))
    elif scene.visual_mode == "three_path_diagram":
        _draw_text_block(draw, (margin, margin), "Decide the three paths first.", font=title_font, fill=(248, 250, 252), max_width=w - 2 * margin, spacing=12)
        start = (w // 2, int(h * 0.31))
        draw.rounded_rectangle((start[0] - 120, start[1] - 48, start[0] + 120, start[1] + 48), radius=20, fill=(30, 41, 59), outline=accent, width=3)
        draw.text((start[0] - 72, start[1] - 16), "Input", font=mid_font, fill=(226, 232, 240))
        labels = [("Normal", "Go through", green), ("Unclear", "Review", amber), ("Weird/Risky", "Stop", red)]
        for idx, (top, bottom, color) in enumerate(labels):
            reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
            bx = margin
            by = int(h * (0.48 + idx * 0.14))
            ex = int(bx + (w - 2 * margin) * reveal)
            draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
            draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)
            draw.text((margin + 28, by + 18), f"{top} → {bottom}", font=mid_font, fill=(248, 250, 252))
    else:
        zoom = 1 + 0.03 * p
        box = (margin, int(h * 0.32), w - margin, int(h * 0.66))
        draw.rounded_rectangle(box, radius=30, fill=(15, 23, 42), outline=green, width=5)
        _draw_text_block(draw, (margin + 32, int(h * 0.40)), scene.renderer_text, font=title_font, fill=(248, 250, 252), max_width=w - 2 * margin - 64, spacing=14)
        draw.text((margin, int(h * 0.76)), "Systems over prompts.", font=mid_font, fill=accent)
        _ = zoom
    return image

def render_motion_preview(
    plan: MotionRenderPlan,
    *,
    runner: Runner = subprocess.run,
    max_frames_per_scene: int | None = None,
) -> MotionRenderResult:
    validation = validate_motion_render_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))

    frame_dir = Path(plan.frame_dir)
    frame_dir.mkdir(parents=True, exist_ok=True)
    Path(plan.output_dir).mkdir(parents=True, exist_ok=True)
    frame_paths: list[str] = []
    frame_index = 0
    for scene in plan.final_plan.scenes:
        scene_duration = (scene.end_ms - scene.start_ms) / 1000
        count = max(1, round(scene_duration * plan.fps))
        if max_frames_per_scene is not None:
            count = min(count, max_frames_per_scene)
        for local_index in range(count):
            progress = local_index / max(1, count - 1)
            image = _render_scene(plan, scene, progress)
            path = frame_dir / f"frame_{frame_index:05d}.png"
            image.save(path, format="PNG")
            frame_paths.append(str(path))
            frame_index += 1

    concat_path = Path(plan.concat_path)
    frame_duration = 1 / plan.fps
    concat_path.write_text("".join(f"file '{Path(path).resolve()}'\nduration {frame_duration:.8f}\n" for path in frame_paths), encoding="utf-8")
    command = [
        "ffmpeg",
        "-y",
        "-f",
        "concat",
        "-safe",
        "0",
        "-i",
        str(concat_path),
        "-vf",
        "format=yuv420p",
        "-r",
        str(plan.fps),
        "-c:v",
        "libx264",
        "-preset",
        "veryfast",
        "-movflags",
        "+faststart",
        plan.output_path,
    ]
    completed = runner(command, capture_output=True, text=True, check=False)
    return MotionRenderResult(
        output_path=plan.output_path,
        frame_paths=tuple(frame_paths),
        concat_path=plan.concat_path,
        returncode=completed.returncode,
        stdout=completed.stdout,
        stderr=completed.stderr,
    )


terminal

pytest tests/test_final_video_plan.py tests/test_motion_renderer.py -q


Output:
....                                                                     [100%]
4 passed in 0.27s


Explanation:
**Generating image for video**

I need to generate an image with the ChatGPT Image tool. The user mentioned I can use it, so I'll likely create a background image to incorporate. However, I'm realizing the current motion renderer doesn't support background assets, so I might need to generate one separately as a keyframe or reference. I could then integrate the image manually later. For creating the video, I need to ensure it's combined with audio and captions in the final version, rendering a preview first. I'll go ahead and generate the background now!

Prompt: Vertical 9:16 cinematic background for a modern AI automation short. A clean laptop/workspace scene with a dark minimal productivity dashboard glow, subtle blue and green light accents, premium tech aesthetic, shallow depth of field, no readable text, no logos, no watermarks, no UI labels, empty central space for overlaid renderer-owned text and diagrams. Realistic but abstract enough to serve as background texture.


Prompt: Vertical 9:16 cinematic background for a modern AI automation short. A clean laptop/workspace scene with a dark minimal productivity dashboard glow, subtle blue and green light accents, premium tech aesthetic, shallow depth of field, no readable text, no logos, no watermarks, no UI labels, empty central space for overlaid renderer-owned text and diagrams. Realistic but abstract enough to serve as background texture.


Explanation:
**Integrating Backgrounds Into Rendering**

I'm considering integrating an optional background into the renderer. I think it makes sense to add an `optional background_path` to the `MotionRenderPlan`, and then ensure the render function uses it. For test-driven development, I'll need to confirm that the ChatGPT image background plan includes this path and that no AI text is allowed. Since the code is already there, I'll write a failing test for the new behavior before implementation. Sounds like a solid plan!

patch

    render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5)

    assert render_plan.width == 540

    background = tmp_path / "chatgpt-image-background.png"
    background.write_bytes(b"fake image")
    render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5, background_path=background)

    assert render_plan.background_path == str(background)
    assert render_plan.width == 540


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_motion_renderer.py
@@ -15,8 +15,11 @@
     voiceover = build_truetrace_final_voiceover("truetrace-ai-automation-quiet-rot-38s")
     final_plan = build_truetrace_weird_email_final_video_plan(voiceover)

-    render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5)
+    background = tmp_path / "chatgpt-image-background.png"
+    background.write_bytes(b"fake image")
+    render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5, background_path=background)

+    assert render_plan.background_path == str(background)
     assert render_plan.width == 540
     assert render_plan.height == 960
     assert render_plan.fps == 24

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_motion_renderer.py">
ERROR [20:96] No parameter named "background_path" [reportCallIssue] (Pyright)
</diagnostics>


terminal

Output:
F                                                                        [100%]
=================================== FAILURES ===================================
________ test_build_motion_render_plan_maps_final_plan_to_frame_output _________

tmp_path = PosixPath('/tmp/pytest-of-agent/pytest-126/test_build_motion_render_plan_0')

    def test_build_motion_render_plan_maps_final_plan_to_frame_output(tmp_path):
        voiceover = build_truetrace_final_voiceover("truetrace-ai-automation-quiet-rot-38s")
        final_plan = build_truetrace_weird_email_final_video_plan(voiceover)

        background = tmp_path / "chatgpt-image-background.png"
        background.write_bytes(b"fake image")
>       render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5, background_path=background)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: build_motion_render_plan() got an unexpected keyword argument 'background_path'

tests/test_motion_renderer.py:20: TypeError
=========================== short test summary info ============================
FAILED tests/test_motion_renderer.py::test_build_motion_render_plan_maps_final_plan_to_frame_output
1 failed in 0.11s


patch

    background_path: str | None = None
    side_effects: tuple[str, ...] = ()

    fps: int
    side_effects: tuple[str, ...] = ()


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -25,7 +25,7 @@
     output_path: str
     width: int
     height: int
-    fps: int
+    background_path: str | None = None
     side_effects: tuple[str, ...] = ()
     external_calls: tuple[str, ...] = ()

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py">
ERROR [63:9] No parameter named "fps" [reportCallIssue] (Pyright)
ERROR [77:13] Cannot access attribute "fps" for class "MotionRenderPlan"
  Attribute "fps" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [235:52] Cannot access attribute "fps" for class "MotionRenderPlan"
  Attribute "fps" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [247:31] Cannot access attribute "fps" for class "MotionRenderPlan"
  Attribute "fps" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [261:18] Cannot access attribute "fps" for class "MotionRenderPlan"
  Attribute "fps" is unknown [reportAttributeAccessIssue] (Pyright)
</diagnostics>


Explanation:
**Correcting background settings**

I realized I accidentally replaced the frames per second (fps). I need to make sure to add the background setting properly after adjusting the fps. It’s important to get the right visuals overall since the background plays a crucial role in the final output. Let's take a moment to ensure everything's aligned before finalizing. I want to make sure everything looks great!

    fps: int
    background_path: str | None = None
    side_effects: tuple[str, ...] = ()


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -25,6 +25,7 @@
     output_path: str
     width: int
     height: int
+    fps: int
     background_path: str | None = None
     side_effects: tuple[str, ...] = ()
     external_calls: tuple[str, ...] = ()


def build_motion_render_plan(
    final_plan: FinalVideoPlan,
    *,
    output_dir: str | Path,
    preview_scale: float = 1.0,
    background_path: str | Path | None = None,
) -> MotionRenderPlan:

def build_motion_render_plan(final_plan: FinalVideoPlan, *, output_dir: str | Path, preview_scale: float = 1.0) -> MotionRenderPlan:


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -43,7 +43,13 @@
     external_calls: tuple[str, ...] = ("ffmpeg",)

-def build_motion_render_plan(final_plan: FinalVideoPlan, *, output_dir: str | Path, preview_scale: float = 1.0) -> MotionRenderPlan:
+def build_motion_render_plan(
+    final_plan: FinalVideoPlan,
+    *,
+    output_dir: str | Path,
+    preview_scale: float = 1.0,
+    background_path: str | Path | None = None,
+) -> MotionRenderPlan:
     validation = validate_final_video_plan(final_plan)
     if not validation.is_valid:
         raise ValueError("; ".join(validation.errors))


        height=height,
        fps=fps,
        background_path=str(background_path) if background_path is not None else None,
    )

        height=height,
        fps=fps,
    )


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -68,6 +68,7 @@
         width=width,
         height=height,
         fps=fps,
+        background_path=str(background_path) if background_path is not None else None,
     )
     validation = validate_motion_render_plan(plan)
     if not validation.is_valid:


def _render_scene(plan: MotionRenderPlan, scene: FinalVideoScene, progress: float, *, background: Image.Image | None = None) -> Image.Image:
    w, h = plan.width, plan.height
    if background is None:
        image = Image.new("RGB", (w, h), (6, 12, 24))
    else:
        image = background.copy()
        overlay = Image.new("RGBA", (w, h), (3, 7, 18, 178))
        image = Image.alpha_composite(image.convert("RGBA"), overlay).convert("RGB")
    draw = ImageDraw.Draw(image)

def _render_scene(plan: MotionRenderPlan, scene: FinalVideoScene, progress: float) -> Image.Image:
    w, h = plan.width, plan.height
    image = Image.new("RGB", (w, h), (6, 12, 24))
    draw = ImageDraw.Draw(image)


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -151,9 +151,14 @@
     _draw_text_block(draw, (x1 + 28, y1 + 86), body, font=body_font, fill=(51, 65, 85), max_width=x2 - x1 - 56, spacing=8)

-def _render_scene(plan: MotionRenderPlan, scene: FinalVideoScene, progress: float) -> Image.Image:
+def _render_scene(plan: MotionRenderPlan, scene: FinalVideoScene, progress: float, *, background: Image.Image | None = None) -> Image.Image:
     w, h = plan.width, plan.height
-    image = Image.new("RGB", (w, h), (6, 12, 24))
+    if background is None:
+        image = Image.new("RGB", (w, h), (6, 12, 24))
+    else:
+        image = background.copy()
+        overlay = Image.new("RGBA", (w, h), (3, 7, 18, 178))
+        image = Image.alpha_composite(image.convert("RGBA"), overlay).convert("RGB")
     draw = ImageDraw.Draw(image)
     accent = (34, 211, 238)
     green = (52, 211, 153)


    frame_paths: list[str] = []
    background = None
    if plan.background_path:
        background = Image.open(plan.background_path).convert("RGB").resize((plan.width, plan.height), Image.Resampling.LANCZOS)
    frame_index = 0

    frame_paths: list[str] = []
    frame_index = 0


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -242,6 +242,9 @@
     frame_dir.mkdir(parents=True, exist_ok=True)
     Path(plan.output_dir).mkdir(parents=True, exist_ok=True)
     frame_paths: list[str] = []
+    background = None
+    if plan.background_path:
+        background = Image.open(plan.background_path).convert("RGB").resize((plan.width, plan.height), Image.Resampling.LANCZOS)
     frame_index = 0
     for scene in plan.final_plan.scenes:
         scene_duration = (scene.end_ms - scene.start_ms) / 1000


image = _render_scene(plan, scene, progress, background=background)

image = _render_scene(plan, scene, progress)


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -253,7 +253,7 @@
             count = min(count, max_frames_per_scene)
         for local_index in range(count):
             progress = local_index / max(1, count - 1)
-            image = _render_scene(plan, scene, progress)
+            image = _render_scene(plan, scene, progress, background=background)
             path = frame_dir / f"frame_{frame_index:05d}.png"
             image.save(path, format="PNG")
             frame_paths.append(str(path))


terminal

pytest tests/test_motion_renderer.py tests/test_final_video_plan.py -q


Output:
....                                                                     [100%]
4 passed in 0.31s


Explanation:
**Generating final preview**

I need to generate an actual render and think about running it fully later. First, I’ll create a motion preview with a background and see if I can reuse existing audio. I should use the current function and mux in the captions. For the final preview, it’ll be important to include subtitles, and I might need to trim it to the audio. I think I need to prepare for generating around 888 frames, but I might target a shorter duration. Let's get started!

python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.probe import probe_media_duration_seconds
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.motion_renderer import build_motion_render_plan, render_motion_preview

candidate_id = 'truetrace-ai-automation-quiet-rot-38s'
output_dir = Path('data/previews/truetrace-final-motion-v1') / candidate_id
background = Path('/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png')
voiceover = build_truetrace_final_voiceover(candidate_id)
final_plan = build_truetrace_weird_email_final_video_plan(voiceover)
motion_plan = build_motion_render_plan(final_plan, output_dir=output_dir, preview_scale=0.5, background_path=background)
motion = render_motion_preview(motion_plan)
if motion.returncode != 0:
    raise SystemExit(motion.stderr)
voice_path = output_dir / 'voice.mp3'
voice = synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=build_default_truetrace_tts_plan(), output_path=str(voice_path)))
audio_duration = probe_media_duration_seconds(voice.output_path)
subtitles = build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
final_preview_plan = build_audio_caption_preview_plan(
    video_path=motion.output_path,
    audio_path=voice.output_path,
    subtitle_track=subtitles,
    output_path=output_dir / 'final-motion-audio-caption-preview.mp4',
    duration_mode='trim_to_audio',
)
final = render_audio_caption_preview(final_preview_plan)
if final.returncode != 0:
    raise SystemExit(final.stderr)
print('background', background)
print('motion', motion.output_path)
print('frames', len(motion.frame_paths))
print('audio_duration', f'{audio_duration:.3f}')
print('subtitle_duration', subtitles.cues[-1].end_ms / 1000)
print('final', final.output_path)
PY
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
sha256sum data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Output:
background /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png
motion data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/motion-preview.mp4
frames 887
audio_duration 36.864
subtitle_duration 37.0
final data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=36.833333
codec_name=aac
codec_type=audio
duration=36.864000
9f7be649bda32aa0c3c5460ac94cdb7a40ed7e6e57267843aa863fa459112edd  data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Explanation:
**Considering video extraction methods**

I need to inspect some screenshots or frames in a video. I’m thinking about using ffmpeg to extract the middle frames, which could be a good way to analyze the content. I’ll have to use the terminal for this. It's a little technical, but it seems like the right tool for the job.  I might also want to research how to get the exact frames I need. Let's see!

mkdir -p /tmp/truetrace_review && ffmpeg -y -i data/previews/truetrace-final-motion-v1/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 -vf "select='eq(n,0)+eq(n,120)+eq(n,300)+eq(n,520)+eq(n,760)'" -vsync 0 /tmp/truetrace_review/frame_%02d.png >/dev/null 2>&1 && printf '%s\n' /tmp/truetrace_review/frame_*.png


Output:
/tmp/truetrace_review/frame_01.png
/tmp/truetrace_review/frame_02.png
/tmp/truetrace_review/frame_03.png
/tmp/truetrace_review/frame_04.png
/tmp/truetrace_review/frame_05.png


Question: Review this mid-video frame for mobile readability, whether it looks like a mechanism reveal rather than static cards, and whether any text appears clipped.


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

Question: Review this mid-video frame for mobile readability, whether it looks like a mechanism reveal rather than static cards, and whether any text appears clipped.


Explanation:
**Inspecting subtitle issues**

I'm thinking about whether to note that the subtitle might need to be clipped or shown only partially at the bottom. It seems like having the bottom text continue onto the next line could be messy. I should probably inspect it further, maybe specifically looking at frame_04. I'm curious to see if that will help resolve the subtitle display issue! Let's see what adjustments can be made to improve clarity.

Question: Review this three-way split frame for readability and clipping. Is the mechanism clear?


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

Question: Review this three-way split frame for readability and clipping. Is the mechanism clear?


Explanation:
**Fixing clipping issues in Frame 4**

I see that Frame 4 has some clipping and overlap at the bottom, so I need to address that. I’m contemplating whether to reduce the subtitle font or adjust the safe zone because the final preview shows the subtitles overlapping the diagram. The scene layout might need a change, like lifting boxes or repositioning the captions to avoid overlap. I’m thinking a smaller font might help, and I need to ensure everything looks visually correct without affecting the diagram too much. Let's patch this up!


patch

--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -213,7 +213,7 @@
         for idx, (top, bottom, color) in enumerate(labels):
             reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
             bx = margin
-            by = int(h * (0.48 + idx * 0.14))
+            by = int(h * (0.42 + idx * 0.12))
             ex = int(bx + (w - 2 * margin) * reveal)
             draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
             draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)


terminal

python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.probe import probe_media_duration_seconds
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.motion_renderer import build_motion_render_plan, render_motion_preview

candidate_id = 'truetrace-ai-automation-quiet-rot-38s'
output_dir = Path('data/previews/truetrace-final-motion-v2') / candidate_id
background = Path('/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png')
voiceover = build_truetrace_final_voiceover(candidate_id)
final_plan = build_truetrace_weird_email_final_video_plan(voiceover)
motion = render_motion_preview(build_motion_render_plan(final_plan, output_dir=output_dir, preview_scale=0.5, background_path=background))
if motion.returncode != 0:
    raise SystemExit(motion.stderr)
voice_path = output_dir / 'voice.mp3'
voice = synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=build_default_truetrace_tts_plan(), output_path=str(voice_path)))
audio_duration = probe_media_duration_seconds(voice.output_path)
subtitles = build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
final = render_audio_caption_preview(build_audio_caption_preview_plan(
    video_path=motion.output_path,
    audio_path=voice.output_path,
    subtitle_track=subtitles,
    output_path=output_dir / 'final-motion-audio-caption-preview.mp4',
    duration_mode='trim_to_audio',
))
if final.returncode != 0:
    raise SystemExit(final.stderr)
print('final', final.output_path)
print('frames', len(motion.frame_paths))
print('audio_duration', f'{audio_duration:.3f}')
PY
rm -rf /tmp/truetrace_review_v2 && mkdir -p /tmp/truetrace_review_v2 && ffmpeg -y -i data/previews/truetrace-final-motion-v2/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 -vf "select='eq(n,520)'" -vsync 0 /tmp/truetrace_review_v2/frame_04.png >/dev/null 2>&1
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-final-motion-v2/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
sha256sum data/previews/truetrace-final-motion-v2/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Output:
final data/previews/truetrace-final-motion-v2/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
frames 887
audio_duration 36.864
codec_name=h264
codec_type=video
width=540
height=960
duration=36.833333
codec_name=aac
codec_type=audio
duration=36.864000
ac6c1c4a637d09051de3fa71fcc3a56546136787d88b3aaaf3990ffce7ec9b0e  data/previews/truetrace-final-motion-v2/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Question: Review this updated three-way split frame for readability and clipping. Is subtitle overlap resolved?


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

Question: Review this updated three-way split frame for readability and clipping. Is subtitle overlap resolved?


Explanation:
**Adjusting layout elements**

I’m noticing some overlap with the bottom part of the design, where the subtitle overlaps the third box. I think I might need to move the subtitles lower. The bottom text is at 740, and the third section ends at 727, so it’s close. To fix this, I could move the diagram further up by adjusting it. That sounds like a good plan! Maybe I should also consider making the subtitle shorter.


patch

--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -213,7 +213,7 @@
         for idx, (top, bottom, color) in enumerate(labels):
             reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
             bx = margin
-            by = int(h * (0.42 + idx * 0.12))
+            by = int(h * (0.37 + idx * 0.105))
             ex = int(bx + (w - 2 * margin) * reveal)
             draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
             draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)


terminal

python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.probe import probe_media_duration_seconds
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.motion_renderer import build_motion_render_plan, render_motion_preview
candidate_id='truetrace-ai-automation-quiet-rot-38s'
output_dir=Path('data/previews/truetrace-final-motion-v3')/candidate_id
background=Path('/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png')
voiceover=build_truetrace_final_voiceover(candidate_id)
final_plan=build_truetrace_weird_email_final_video_plan(voiceover)
motion=render_motion_preview(build_motion_render_plan(final_plan, output_dir=output_dir, preview_scale=0.5, background_path=background))
if motion.returncode: raise SystemExit(motion.stderr)
voice=synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=build_default_truetrace_tts_plan(), output_path=str(output_dir/'voice.mp3')))
audio_duration=probe_media_duration_seconds(voice.output_path)
subtitles=build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
final=render_audio_caption_preview(build_audio_caption_preview_plan(video_path=motion.output_path,audio_path=voice.output_path,subtitle_track=subtitles,output_path=output_dir/'final-motion-audio-caption-preview.mp4',duration_mode='trim_to_audio'))
if final.returncode: raise SystemExit(final.stderr)
print(final.output_path)
PY
rm -rf /tmp/truetrace_review_v3 && mkdir -p /tmp/truetrace_review_v3 && ffmpeg -y -i data/previews/truetrace-final-motion-v3/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 -vf "select='eq(n,520)'" -vsync 0 /tmp/truetrace_review_v3/frame_04.png >/dev/null 2>&1
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-final-motion-v3/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
sha256sum data/previews/truetrace-final-motion-v3/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Output:
data/previews/truetrace-final-motion-v3/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=36.833333
codec_name=aac
codec_type=audio
duration=36.864000
e703b2b9fe5574f866a7bdf415de508f357cbc972fd6ebc5b4bb79360b9e51a6  data/previews/truetrace-final-motion-v3/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Question: Review this updated three-way split frame for readability and clipping. Is subtitle overlap resolved enough for a Telegram review prototype?


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

Question: Review this updated three-way split frame for readability and clipping. Is subtitle overlap resolved enough for a Telegram review prototype?


patch

        positions = []
        for idx, (top, bottom, color) in enumerate(labels):
            reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
            by = int(h * (0.37 + idx * 0.105))
            ex = int(margin + (w - 2 * margin) * reveal)
            positions.append((top, bottom, color, by, ex))
            draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
        for top, bottom, color, by, _ex in positions:
            draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)
            draw.text((margin + 28, by + 18), f"{top} → {bottom}", font=mid_font, fill=(248, 250, 252))

        for idx, (top, bottom, color) in enumerate(labels):
            reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
            bx = margin
            by = int(h * (0.37 + idx * 0.105))
            ex = int(bx + (w - 2 * margin) * reveal)
            draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
            draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)
            draw.text((margin + 28, by + 18), f"{top} → {bottom}", font=mid_font, fill=(248, 250, 252))


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -210,12 +210,14 @@
         draw.rounded_rectangle((start[0] - 120, start[1] - 48, start[0] + 120, start[1] + 48), radius=20, fill=(30, 41, 59), outline=accent, width=3)
         draw.text((start[0] - 72, start[1] - 16), "Input", font=mid_font, fill=(226, 232, 240))
         labels = [("Normal", "Go through", green), ("Unclear", "Review", amber), ("Weird/Risky", "Stop", red)]
+        positions = []
         for idx, (top, bottom, color) in enumerate(labels):
             reveal = min(1.0, max(0.0, (progress - idx * 0.18) / 0.35))
-            bx = margin
             by = int(h * (0.37 + idx * 0.105))
-            ex = int(bx + (w - 2 * margin) * reveal)
+            ex = int(margin + (w - 2 * margin) * reveal)
+            positions.append((top, bottom, color, by, ex))
             draw.line((start[0], start[1] + 58, ex, by + 45), fill=color, width=7)
+        for top, bottom, color, by, _ex in positions:
             draw.rounded_rectangle((margin, by, w - margin, by + int(h * 0.10)), radius=20, fill=(15, 23, 42), outline=color, width=4)
             draw.text((margin + 28, by + 18), f"{top} → {bottom}", font=mid_font, fill=(248, 250, 252))
     else:


terminal

python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.probe import probe_media_duration_seconds
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.motion_renderer import build_motion_render_plan, render_motion_preview
candidate_id='truetrace-ai-automation-quiet-rot-38s'
output_dir=Path('data/previews/truetrace-final-motion-v4')/candidate_id
background=Path('/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png')
voiceover=build_truetrace_final_voiceover(candidate_id)
final_plan=build_truetrace_weird_email_final_video_plan(voiceover)
motion=render_motion_preview(build_motion_render_plan(final_plan, output_dir=output_dir, preview_scale=0.5, background_path=background))
if motion.returncode: raise SystemExit(motion.stderr)
voice=synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=build_default_truetrace_tts_plan(), output_path=str(output_dir/'voice.mp3')))
audio_duration=probe_media_duration_seconds(voice.output_path)
subtitles=build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
final=render_audio_caption_preview(build_audio_caption_preview_plan(video_path=motion.output_path,audio_path=voice.output_path,subtitle_track=subtitles,output_path=output_dir/'final-motion-audio-caption-preview.mp4',duration_mode='trim_to_audio'))
if final.returncode: raise SystemExit(final.stderr)
print(final.output_path)
PY
rm -rf /tmp/truetrace_review_v4 && mkdir -p /tmp/truetrace_review_v4 && ffmpeg -y -i data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 -vf "select='eq(n,520)'" -vsync 0 /tmp/truetrace_review_v4/frame_04.png >/dev/null 2>&1
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
sha256sum data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Output:
data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=36.833333
codec_name=aac
codec_type=audio
duration=36.864000
3893b94eabf353e6388d1ca8b1fc06bae19cb216cc98f6b44956739e58853051  data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


Question: Final check: is the three-way split readable, arrows behind labels, and no critical clipping/overlap for a review prototype?


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

Question: Final check: is the three-way split readable, arrows behind labels, and no critical clipping/overlap for a review prototype?


Explanation:
**Considering documentation updates**

I need to update the README and documentation, and then ensure that all tests are complete. But first, I might generate the image asset for the final video. It could be a good idea to mention ChatGPT Image through the Codex, as it relates. After that, I’ll commit everything and add the documentation. This is feeling like a productive plan! I want to make sure everything is in order.

patch

- `autoshorts.rendering.final_video_plan` — final-near 1080x1920 production plans that keep AI visuals text-free and renderer-owned.
- `autoshorts.rendering.motion_renderer` — procedural motion preview renderer for inbox cards, warning routes, and three-path mechanism diagrams before heavier AI-video work.
- `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -35,6 +35,8 @@
 - `autoshorts.strategy.brand` — TrueTraceShorts brand profile, default descriptions/bios/tags, and brand-fit validation.
 - `autoshorts.strategy.score` — follower-conversion strategy scoring for hook, curiosity, identity, emotion, trust, follow reason, originality, and variety.
 - `autoshorts.strategy.series` — public-safe series registry and hard no-go validation before public content advances.
+- `autoshorts.rendering.final_video_plan` — final-near 1080x1920 production plans that keep AI visuals text-free and renderer-owned.
+- `autoshorts.rendering.motion_renderer` — procedural motion preview renderer for inbox cards, warning routes, and three-path mechanism diagrams before heavier AI-video work.
 - `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
 - `autoshorts.rendering.ffmpeg_renderer` — explicit FFmpeg boundary that writes concat files and MP4 timing previews.
 - `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.

No linter for .md files

/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md was modified by sibling subagent '20260524_204754_10d508' but this agent never read it. Read the file before writing to avoid overwriting the sibling's changes.


The first public-facing audio/caption pilot now uses the accessible `weird email` framing: top card, scene captions, subtitles, and voiceover are derived from the final voiceover source instead of mixing old approval-card text with new spoken copy.

The first final-near motion prototype uses a hybrid model: ChatGPT Image/Codex can provide text-free background texture, while `autoshorts.rendering.motion_renderer` owns the actual inbox cards, arrows, labels, captions, and FFmpeg assembly. This keeps generated imagery useful without trusting it to spell or explain anything.

## Strategy scoring

The first public-facing audio/caption pilot now uses the accessible `weird email` framing: top card, scene captions, subtitles, and voiceover are derived from the final voiceover source instead of mixing old approval-card text with new spoken copy.

## Strategy scoring


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -86,6 +86,8 @@

 The first public-facing audio/caption pilot now uses the accessible `weird email` framing: top card, scene captions, subtitles, and voiceover are derived from the final voiceover source instead of mixing old approval-card text with new spoken copy.

+The first final-near motion prototype uses a hybrid model: ChatGPT Image/Codex can provide text-free background texture, while `autoshorts.rendering.motion_renderer` owns the actual inbox cards, arrows, labels, captions, and FFmpeg assembly. This keeps generated imagery useful without trusting it to spell or explain anything.
+
 ## Strategy scoring

 `autoshorts.strategy.score.ContentStrategyScore` evaluates whether a candidate can move beyond generic AI tips and plausibly convert viewers into followers. It scores:

No linter for .md files


# Final Motion Prototype Notes

## Decision

For the first real TrueTrace video prototype, use a hybrid rendering stack:

```text
ChatGPT Image / Codex image generation → text-free cinematic background texture
Procedural motion renderer → all cards, labels, arrows, diagrams, and mechanism text
EdgeTTS → voiceover
ASS/FFmpeg → deterministic subtitle burn-in and final assembly
```

## Why

The pilot is a mechanism-reveal short, not a cinematic story scene. AI image/video can provide mood and texture, but it must not own:

- email text;
- path labels;
- arrows;
- captions;
- factual claims;
- the three-way workflow mechanism.

Those layers stay renderer-owned so the final video remains readable on mobile and does not contain fake/generated UI text.

## Current pilot

Candidate:

```text
truetrace-ai-automation-quiet-rot-38s
```

Frame sequence:

```text
clean-demo
→ weird-email
→ wrong-routing
→ three-way-split
→ final-rule
```

Core visual mechanism:

```text
Normal → Go through
Unclear → Review
Weird/Risky → Stop
```

## Generated prototype artifact

The current review artifact is local-only and ignored by Git:

```text
data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
```

It uses one ChatGPT Image/Codex-generated text-free background plus deterministic renderer overlays.

## Quality notes

- The three-way split scene has been visually checked so arrows sit behind labels and subtitles do not critically overlap the mechanism.
- The prototype is still a review prototype, not a final public export.
- Next polish should improve subtitle phrasing/timing around the three-way split and optionally replace the single static background with per-scene generated backgrounds or short I2V texture clips.


No linter for .md files


terminal

Output:
........................................................................ [ 43%]
........................................................................ [ 86%]
......................                                                   [100%]
166 passed in 33.16s


git status --short && git add autoshorts/rendering/final_video_plan.py autoshorts/rendering/motion_renderer.py tests/test_final_video_plan.py tests/test_motion_renderer.py README.md docs/final_motion_prototype.md && git commit -m "feat: add TrueTrace final motion prototype" && git status --short && git log --oneline -5


Output:
M README.md
?? autoshorts/rendering/final_video_plan.py
?? autoshorts/rendering/motion_renderer.py
?? docs/final_motion_prototype.md
?? tests/test_final_video_plan.py
?? tests/test_motion_renderer.py
[feat/core-approval-quality-gates 14310f9] feat: add TrueTrace final motion prototype
 6 files changed, 665 insertions(+)
 create mode 100644 autoshorts/rendering/final_video_plan.py
 create mode 100644 autoshorts/rendering/motion_renderer.py
 create mode 100644 docs/final_motion_prototype.md
 create mode 100644 tests/test_final_video_plan.py
 create mode 100644 tests/test_motion_renderer.py
14310f9 feat: add TrueTrace final motion prototype
7375311 feat: make TrueTrace pilot beginner friendly
b9b6423 feat: align TrueTrace voiceover previews
3f68001 feat: add EdgeTTS audio caption preview
066201d feat: attach audio plans to TrueTrace previews


Erledigt, Sir — ich habe losgelegt und den ersten **richtigen Motion-Prototypen** gebaut, mit ChatGPT Image/Codex als Hintergrund-Quelle statt lokalem Qwen Image.

## Gebaut

Neue Module:

```text
autoshorts/rendering/final_video_plan.py
autoshorts/rendering/motion_renderer.py
```

Neue Tests:

```text
tests/test_final_video_plan.py
tests/test_motion_renderer.py
```

Neue Doku:

```text
docs/final_motion_prototype.md
```

Commit:

```text
14310f9 feat: add TrueTrace final motion prototype
```

## Was jetzt neu ist

### 1. FinalVideoPlan

Neues Artefakt:

```python
FinalVideoPlan
```

Es definiert die echten Produktionsszenen:

```text
clean-demo
→ weird-email
→ wrong-routing
→ three-way-split
→ final-rule
```

Mit:

```text
1080x1920
30fps
37s duration
renderer-owned text
no AI-generated text allowed
```

Wichtig: AI darf nur Hintergrund/Textur liefern. Der Renderer besitzt weiterhin:

```text
email text
labels
arrows
workflow paths
subtitles
captions
```

Also keine „AI hat Review als Reviwe geschrieben“-Katastrophe. Fortschritt, mit Helm.

---

### 2. ChatGPT Image/Codex als Background-Quelle genutzt

Ich habe über `image_generate` / OpenAI Codex ein textfreies vertikales Hintergrundbild erzeugt:

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

Prompt-Kern:

```text
modern AI automation short background,
clean laptop/workspace,
dark productivity dashboard glow,
blue/green accents,
no readable text,
no logos,
empty central space for renderer-owned overlays
```

Das Bild wird im Motion Renderer als visuelle Textur verwendet. Alle erklärenden Elemente liegen darüber als deterministic overlays.

---

### 3. Procedural Motion Renderer

Neuer Renderer:

```python
build_motion_render_plan()
render_motion_preview()
```

Er erzeugt lokal:

```text
animated inbox cards
weird email card
wrong-routing warning
three-way split diagram
final rule card
```

Mit FFmpeg-Ausgabe:

```text
motion-preview.mp4
```

Dann wird wie bisher ergänzt:

```text
EdgeTTS voiceover
ASS subtitles
FFmpeg audio+caption burn-in
```

---

## Neuer Video-Prototyp

Finaler Review Clip:

MEDIA:/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-final-motion-v4/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4

## Verifiziert

```text
Video: h264
Size: 540x960 review preview
Duration: 36.833s
Audio: aac
Audio duration: 36.864s
```

SHA256:

```text
3893b94eabf353e6388d1ca8b1fc06bae19cb216cc98f6b44956739e58853051
```

Frames gerendert:

```text
887
```

## Visuelle QA

Ich habe die kritische `three-way-split` Szene extrahiert und visuell geprüft.

Fixes gemacht:

- Diagramm weiter nach oben geschoben.
- Arrows hinter die Labels gelegt.
- Subtitle-Overlap reduziert.
- `Normal → Go through`, `Unclear → Review`, `Weird/Risky → Stop` bleibt lesbar.

Die Szene ist jetzt für einen Review-Prototyp brauchbar. Nicht Cannes. Aber klarer als die vorherigen Karten — also bereits ein zivilisatorischer Sprung.

## Tests

Full Suite:

```text
166 passed in 33.16s
```

## Architektur jetzt

```text
FinalVoiceoverScript
→ FinalVideoPlan
→ ChatGPT Image text-free background
→ MotionRenderer procedural overlays
→ EdgeTTS voiceover
→ ASS subtitles
→ FFmpeg final preview
```

## Wichtig zur Richtung

Das ist jetzt der richtige Ansatz:

```text
ChatGPT Image / Codex:
Mood, background, cinematic texture

Renderer:
Truth, labels, arrows, captions, mechanism

FFmpeg:
Assembly, audio, subtitle burn-in
```

Nicht:

```text
AI soll bitte alles generieren und korrekt buchstabieren
```

Denn das wäre wieder GPU-Roulette mit Bildungsauftrag.

## Nächster sinnvoller Schritt

Jetzt würde ich den Clip **qualitativ polieren**, nicht nochmal Architektur umbauen:

```text
1. Subtitle phrasing bei der 3-way-split Szene kürzen
2. Scene-specific ChatGPT Image backgrounds erzeugen:
   - clean demo inbox
   - weird email moment
   - abstract AI route
   - mechanism background
3. Motion etwas lebendiger machen:
   - subtle zoom
   - card slide timing
   - warning pulse
   - progress/rhythm beats
4. Danach 1080x1920 final export bauen
```

Der aktuelle Clip ist der erste echte „richtige Video“-Prototyp: Voice, Captions, Motion, Mechanism und ChatGPT-Image-Textur in einer Pipeline.

Explanation:
**Reviewing README changes**

I’m noticing that the README file might not be included in my last commit. The commit indicates 6 files changed, suggesting the README wasn't staged, which is a bit puzzling. I need to run `git add README`. However, it looks like it’s not listed in the creations. I want to ensure the final version is concise, includes media, and mentions the ChatGPT image used. I should also refer to the generated background path and test 166.


[M G] Ok, mach weiter.


Explanation:
**Continuing with skills**

I need to keep going and focus on the relevant skills like TDD, social aspects, and image handling. It might be time to act and polish the subtitle phrasing. I should build tests and inspect the subtitles closely, maybe looking at the final preview to ensure they have the right style. I'll be sure to use the necessary tools in the process to make everything come together.


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.

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


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

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.

## 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.
   - 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
   Render only drafts above the chosen threshold, e.g. 8/10.

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`.
   - 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`.

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/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/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.

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 let German approval previews become unreadable technical cards: use pixel-width-aware wrapping, readable fonts, limited line counts, and visual checks for clipped/overcrowded text.
- 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 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 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.