     1|"""Asset planning for final, motion-led AutoShortsBot renders.
     2|
     3|This layer converts render manifests into explicit asset-generation plans so final
     4|videos can use local procedural motion, screen-recording simulations, kinetic
     5|overlays, and mechanism animations instead of static text slides. The plan is
     6|budget-safe: paid video-credit providers are not required.
     7|"""
     8|
     9|from __future__ import annotations
    10|
    11|import json
    12|from pathlib import Path
    13|from typing import Any
    14|
    15|from autoshorts.script_drafts import _slug
    16|
    17|SCENE_MODE_BY_TYPE = {
    18|    "kinetic_text": "kinetic_text_overlay",
    19|    "screen_recording": "screen_recording_simulation",
    20|    "card_animation": "animated_card",
    21|    "stock_broll": "cinematic_ai_video",
    22|    "generated_background": "cinematic_ai_video",
    23|    "cinematic_ai_video": "cinematic_ai_video",
    24|    "mechanism_animation": "mechanism_animation",
    25|    "kinetic_text_overlay": "kinetic_text_overlay",
    26|}
    27|
    28|
    29|# Scene-level overrides keep AI/productivity videos from becoming five text cards.
    30|SCENE_MODE_OVERRIDES = {
    31|    "ai_life_systems": {
    32|        "scene_01": "cinematic_ai_video",
    33|        "scene_02": "screen_recording_simulation",
    34|        "scene_03": "animated_card",
    35|        "scene_04": "screen_recording_simulation",
    36|        "scene_05": "kinetic_text_overlay",
    37|    },
    38|    "attention_design": {
    39|        "scene_01": "cinematic_ai_video",
    40|        "scene_02": "cinematic_ai_video",
    41|        "scene_03": "animated_card",
    42|        "scene_04": "screen_recording_simulation",
    43|        "scene_05": "kinetic_text_overlay",
    44|    },
    45|}
    46|
    47|
    48|def _visual_items(manifest: dict[str, Any]) -> list[dict[str, Any]]:
    49|    return [item for item in manifest["timeline"] if item["track"] == "visual"]
    50|
    51|
    52|def _duration_seconds(item: dict[str, Any]) -> int:
    53|    return max(3, min(6, round(int(item["duration_ms"]) / 1000)))
    54|
    55|
    56|def _asset_mode(item: dict[str, Any], pillar: str) -> str:
    57|    override = SCENE_MODE_OVERRIDES.get(pillar, {}).get(item["id"])
    58|    if override:
    59|        return override
    60|    return SCENE_MODE_BY_TYPE.get(item["type"], "cinematic_ai_video")
    61|
    62|
    63|def _camera_motion(scene_id: str, mode: str) -> str:
    64|    if mode == "cinematic_ai_video":
    65|        return "slow handheld push-in, subtle parallax, natural micro-movements"
    66|    if mode == "screen_recording_simulation":
    67|        return "cursor movement, typing reveal, zoom to changed prompt, highlighted selection box"
    68|    if mode == "animated_card":
    69|        return "cards slide in one at a time, one concept per beat, slight scale pops"
    70|    return "large kinetic caption words pop and slide with beat-synced emphasis"
    71|
    72|
    73|def _cinematic_subject(pillar: str, text: str) -> str:
    74|    if pillar == "attention_design":
    75|        return (
    76|            "a realistic person holding a phone in a dim modern room, thumb hovering over an endless feed, "
    77|            "then a decisive pause as the screen glow reflects on their face"
    78|        )
    79|    return (
    80|        "a realistic creator at a desk late evening, laptop open to an AI chat, thoughtful but in control, "
    81|        "hands moving between keyboard and notebook"
    82|    )
    83|
    84|
    85|def _generation_prompt(item: dict[str, Any], manifest: dict[str, Any], mode: str) -> str:
    86|    video = manifest["video"]
    87|    text = item["text"]
    88|    if mode == "cinematic_ai_video":
    89|        subject = _cinematic_subject(video["pillar"], text)
    90|        return (
    91|            f"Vertical 9:16 cinematic-looking local motion scene, {subject}. "
    92|            f"Mood: focused, modern, high-retention social video, dramatic lighting simulated with gradients and shadows. "
    93|            f"Scene intent: {item['visual_direction']} Camera: {_camera_motion(item['id'], mode)}. "
    94|            "Use local procedural animation/parallax rather than paid AI-video credits; keep captions renderer-overlay only."
    95|        )
    96|    if mode == "screen_recording_simulation":
    97|        return (
    98|            "Vertical 9:16 UI/screen-recording simulation. Show an AI chat or phone interface being edited live: "
    99|            f"{item['visual_direction']} Use typing, cursor movement, zoom highlights, and before/after prompt changes. "
   100|            "All readable captions must be added by our renderer overlay, not baked into the clip."
   101|        )
   102|    if mode == "animated_card":
   103|        return (
   104|            "Vertical animated explainer card sequence. Minimal dark UI, one idea per beat, animated boxes and arrows. "
   105|            f"Visual task: {item['visual_direction']} Keep it moving; no static slide holds longer than one second."
   106|        )
   107|    if mode == "mechanism_animation":
   108|        return (
   109|            "Vertical 9:16 mechanism-reveal animation. Build a clear visual explanation with moving layers, x-ray/röntgen reveal, "
   110|            "arrows, meters, object cutaways, and sequential labels added by our renderer. "
   111|            f"Mechanism task: {item['visual_direction']} Motion: {item.get('visual_motion', _camera_motion(item['id'], mode))}. "
   112|            "No static holds longer than one second; keep all readable text as renderer overlays."
   113|        )
   114|    return (
   115|        "Kinetic text overlay scene. Use large caption words, fast emphasis, scale pops, and clean background motion. "
   116|        f"Overlay text: {text}"
   117|    )
   118|
   119|
   120|def _negative_prompt(mode: str) -> str:
   121|    if mode == "cinematic_ai_video":
   122|        return (
   123|            "no readable text, no subtitles, no logos, no watermarks, no distorted hands, no fake UI text, "
   124|            "no slideshow, no static image, no uncanny faces"
   125|        )
   126|    if mode == "screen_recording_simulation":
   127|        return "no private data, no real credentials, no illegible tiny UI, no brand logos unless approved"
   128|    if mode == "animated_card":
   129|        return "no dense paragraphs, no static PowerPoint slide, no tiny text, no chart junk"
   130|    if mode == "mechanism_animation":
   131|        return "no static diagram, no dense labels, no tiny text, no unreadable chart junk, no generic infographic"
   132|    return "no long paragraphs, no tiny footnotes, no static card"
   133|
   134|
   135|def _scene_asset_plan(item: dict[str, Any], manifest: dict[str, Any]) -> dict[str, Any]:
   136|    video = manifest["video"]
   137|    mode = _asset_mode(item, video["pillar"])
   138|    return {
   139|        "scene_id": item["id"],
   140|        "asset_ref": item["asset_ref"],
   141|        "asset_mode": mode,
   142|        "provider_family": "local_free_renderer",
   143|        "duration_seconds": _duration_seconds(item),
   144|        "overlay_text": item["text"],
   145|        "caption_strategy": "renderer_overlay_only",
   146|        "no_text_in_video": mode == "cinematic_ai_video",
   147|        "motion_directive": _camera_motion(item["id"], mode),
   148|        "generation_prompt": _generation_prompt(item, manifest, mode),
   149|        "negative_prompt": _negative_prompt(mode),
   150|        "retention_goal": item["retention_goal"],
   151|        "source_visual_direction": item["visual_direction"],
   152|    }
   153|
   154|
   155|def build_asset_plan(manifest: dict[str, Any]) -> dict[str, Any]:
   156|    """Build a final-render asset plan from a render manifest."""
   157|    scenes = [_scene_asset_plan(item, manifest) for item in _visual_items(manifest)]
   158|    modes = [scene["asset_mode"] for scene in scenes]
   159|    return {
   160|        "schema_version": "asset_plan.v2",
   161|        "video": manifest["video"],
   162|        "scenes": scenes,
   163|        "final_render_strategy": {
   164|            "preview_renderer_role": "storyboard_only",
   165|            "budget_policy": "free_only_no_paid_video_credits",
   166|            "static_card_limit": 0,
   167|            "must_use_motion_assets": True,
   168|            "preferred_mix": {
   169|                "cinematic_ai_video": modes.count("cinematic_ai_video"),
   170|                "screen_recording_simulation": modes.count("screen_recording_simulation"),
   171|                "animated_card": modes.count("animated_card"),
   172|                "mechanism_animation": modes.count("mechanism_animation"),
   173|                "kinetic_text_overlay": modes.count("kinetic_text_overlay"),
   174|            },
   175|            "captions": "large kinetic overlays generated by renderer; no baked text in AI clips",
   176|            "approval_required_before_publish": True,
   177|        },
   178|    }
   179|
   180|
   181|def _asset_plan_filename(video: dict[str, Any]) -> str:
   182|    title_slug = _slug(video["title"])
   183|    video_id = video["id"]
   184|    if video_id.endswith(title_slug):
   185|        return f"{video_id}.json"
   186|    return f"{video_id}-{title_slug}.json"
   187|
   188|
   189|def write_asset_plans(manifests: list[dict[str, Any]], output_dir: Path) -> list[Path]:
   190|    """Write asset plans as JSON files."""
   191|    output_dir.mkdir(parents=True, exist_ok=True)
   192|    paths: list[Path] = []
   193|    for manifest in manifests:
   194|        plan = build_asset_plan(manifest)
   195|        path = output_dir / _asset_plan_filename(plan["video"])
   196|        path.write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
   197|        paths.append(path)
   198|    return paths
   199|


Explanation:
**Considering test specifications**

I’m thinking about fixing `duration_seconds` to have a precise float or maintain it less than or equal to a certain value. Tests expect it to be less than or equal to 6 seconds, but I see the mechanism could break with 13 seconds. The goal seems to be capping clips at a maximum of 6 seconds each. I need to write a test file for local rendering. Is it necessary to avoid ffmpeg in unit tests? I want to ensure testing will cover the necessary function.

todo

Repo-Zustand und vorhandene Free-Production-Pipeline prüfen

TDD für Local Motion Renderer v1 schreiben und RED verifizieren

Local Motion Renderer v1 implementieren und Pilot-MP4 rendern

Tests, ffprobe und Git-Status/Commit/Push verifizieren


import json
from pathlib import Path

from autoshorts.local_motion_renderer import local_render_jobs, render_local_motion_video

FIXTURE = Path("data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json")

def _jobs() -> dict:
    return json.loads(FIXTURE.read_text(encoding="utf-8"))

def test_local_render_jobs_filters_out_text_only_nvidia_refinement_jobs():
    jobs = _jobs()

    render_jobs = local_render_jobs(jobs)

    assert len(render_jobs) == 5
    assert {job["provider"] for job in render_jobs} <= {"local_renderer", "local_ffmpeg"}
    assert all(job["cost"] == "free" for job in render_jobs)
    assert all(job["provider"] != "free_nvidia_llm" for job in render_jobs)

def test_render_local_motion_video_writes_vertical_mp4_and_metadata(tmp_path):
    jobs = _jobs()
    output_path = tmp_path / "pilot.mp4"

    result = render_local_motion_video(jobs, output_path, width=180, height=320, fps=4)

    assert output_path.exists()
    assert output_path.stat().st_size > 0
    assert result["output_path"] == output_path
    assert result["video_id"] == "hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann"
    assert result["scene_count"] == 5
    assert result["width"] == 180
    assert result["height"] == 320
    assert result["fps"] == 4
    assert result["paid_credit_providers_used"] == []
    assert result["rendered_duration_seconds"] == sum(job["duration_seconds"] for job in local_render_jobs(jobs))
    assert result["motion_event_count"] >= result["scene_count"] * 2
    assert result["metadata_path"].exists()

    metadata = json.loads(result["metadata_path"].read_text(encoding="utf-8"))
    assert metadata["schema_version"] == "local_motion_render.v1"
    assert metadata["budget_policy"]["paid_video_credits_allowed"] is False
    assert metadata["scene_count"] == 5


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py">
ERROR [4:6] Import "autoshorts.local_motion_renderer" could not be resolved [reportMissingImports] (Pyright)
</diagnostics>


terminal

Output:
==================================== ERRORS ====================================
_____________ ERROR collecting tests/test_local_motion_renderer.py _____________
ImportError while importing test module '/home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_local_motion_renderer.py:4: in <module>
    from autoshorts.local_motion_renderer import local_render_jobs, render_local_motion_video
E   ModuleNotFoundError: No module named 'autoshorts.local_motion_renderer'
=========================== short test summary info ============================
ERROR tests/test_local_motion_renderer.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.10s


"""Local/free motion renderer for AutoShortsBot production jobs.

The renderer intentionally uses only local Pillow/FFmpeg drawing and compositing.
It does not call paid video-generation providers. The output is a deterministic
motion prototype: procedural backgrounds, car/mechanism layers, kinetic captions,
and beat-synced movement based on the free production job specs.
"""

from __future__ import annotations

import json
import math
import shutil
import subprocess
import tempfile
import textwrap
from pathlib import Path
from typing import Any

from PIL import Image, ImageDraw, ImageFont

PAID_VIDEO_PROVIDERS = {"kling", "hailuo", "runway", "veo", "pika", "luma"}
LOCAL_RENDER_PROVIDERS = {"local_renderer", "local_ffmpeg"}

PALETTE_BY_MODE = {
    "cinematic_ai_video": ((7, 12, 25), (30, 64, 175), (248, 113, 113)),
    "mechanism_animation": ((2, 6, 23), (15, 118, 110), (251, 191, 36)),
    "kinetic_text_overlay": ((15, 23, 42), (88, 28, 135), (34, 211, 238)),
    "screen_recording_simulation": ((8, 13, 28), (14, 116, 144), (96, 165, 250)),
    "animated_card": ((17, 24, 39), (120, 53, 15), (251, 146, 60)),
}

def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    candidates = [
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    ]
    for path in candidates:
        if Path(path).exists():
            return ImageFont.truetype(path, size=size)
    return ImageFont.load_default()

def _wrap(text: str, width_chars: int) -> str:
    return "\n".join(textwrap.wrap(" ".join(text.split()), width=max(8, width_chars)))

def _gradient(width: int, height: int, start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image:
    image = Image.new("RGB", (width, height), start)
    draw = ImageDraw.Draw(image)
    for y in range(height):
        ratio = y / max(1, height - 1)
        color = tuple(round(start[i] * (1 - ratio) + end[i] * ratio) for i in range(3))
        draw.line([(0, y), (width, y)], fill=color)
    return image

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

def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
    size = max(22, round(width * (0.062 + 0.014 * pulse)))
    font = _font(size, bold=True)
    wrapped = _wrap(text, max(12, round(width / (size * 0.52))))
    lines = wrapped.splitlines()[:3]
    line_boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
    line_heights = [box[3] - box[1] for box in line_boxes]
    spacing = max(4, round(size * 0.22))
    block_height = sum(line_heights) + spacing * max(0, len(lines) - 1)
    pad = round(width * 0.045)
    y = height - round(height * 0.21) - block_height // 2
    box_top = y - pad
    box_bottom = y + block_height + pad
    draw.rounded_rectangle(
        [round(width * 0.06), box_top, round(width * 0.94), box_bottom],
        radius=round(width * 0.035),
        fill=(255, 255, 255),
        outline=accent,
        width=max(2, width // 180),
    )
    for line, box, line_height in zip(lines, line_boxes, line_heights):
        line_width = box[2] - box[0]
        draw.text(((width - line_width) // 2, y), line, font=font, fill=(15, 23, 42))
        y += line_height + spacing

def _draw_car(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int]) -> None:
    cx = width // 2
    cy = round(height * 0.43)
    scale = width / 540
    bob = round(math.sin(progress * math.tau * 2) * 6 * scale)
    car_w = round(width * 0.58)
    car_h = round(height * 0.12)
    left = cx - car_w // 2
    right = cx + car_w // 2
    top = cy - car_h // 2 + bob
    bottom = cy + car_h // 2 + bob
    draw.rounded_rectangle([left, top, right, bottom], radius=round(24 * scale), fill=(226, 232, 240), outline=accent, width=max(2, round(4 * scale)))
    cabin = [left + round(car_w * 0.23), top - round(car_h * 0.55), right - round(car_w * 0.25), top + round(car_h * 0.15)]
    draw.rounded_rectangle(cabin, radius=round(14 * scale), fill=(148, 163, 184), outline=(226, 232, 240), width=max(1, round(2 * scale)))
    wheel_r = round(width * 0.045)
    for wx in (left + round(car_w * 0.22), right - round(car_w * 0.22)):
        draw.ellipse([wx - wheel_r, bottom - wheel_r, wx + wheel_r, bottom + wheel_r], fill=(2, 6, 23), outline=(248, 250, 252), width=max(2, round(3 * scale)))
        angle = progress * math.tau * 3
        draw.line([wx, bottom, wx + math.cos(angle) * wheel_r, bottom + math.sin(angle) * wheel_r], fill=accent, width=max(1, round(3 * scale)))
    # x-ray scan line and warning pulse
    scan_x = left + round(car_w * _ease(progress))
    draw.line([(scan_x, top - round(60 * scale)), (scan_x, bottom + round(70 * scale))], fill=accent, width=max(2, round(5 * scale)))
    pulse_r = round((30 + 38 * _ease((progress * 2) % 1)) * scale)
    draw.ellipse([cx - pulse_r, cy - pulse_r + bob, cx + pulse_r, cy + pulse_r + bob], outline=accent, width=max(1, round(3 * scale)))

def _draw_mechanism_layers(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int], twist: bool) -> None:
    labels = ["Preis", "Reparatur", "Standtage", "Marge", "Liquidität"]
    visible = max(1, min(len(labels), math.ceil(progress * len(labels))))
    top = round(height * 0.24)
    layer_h = round(height * 0.072)
    gap = round(height * 0.022)
    for index, label in enumerate(labels[:visible]):
        p = _ease(min(1.0, max(0.0, progress * len(labels) - index)))
        x_offset = round((1 - p) * width * 0.45)
        y = top + index * (layer_h + gap)
        color = accent if (twist and index == 0) else (226, 232, 240)
        fill = (30, 41, 59) if not (twist and index == 0) else (127, 29, 29)
        draw.rounded_rectangle(
            [round(width * 0.12) + x_offset, y, round(width * 0.88) + x_offset, y + layer_h],
            radius=round(width * 0.026),
            fill=fill,
            outline=color,
            width=max(2, width // 180),
        )
        f = _font(max(14, round(width * 0.046)), bold=True)
        draw.text((round(width * 0.17) + x_offset, y + round(layer_h * 0.22)), label, font=f, fill=(248, 250, 252))
        meter_w = round(width * 0.24 * p)
        draw.rounded_rectangle(
            [round(width * 0.58) + x_offset, y + round(layer_h * 0.35), round(width * 0.58) + x_offset + meter_w, y + round(layer_h * 0.63)],
            radius=round(width * 0.015),
            fill=color,
        )
    car_y = round(height * 0.72)
    draw.line([(round(width * 0.5), top + visible * (layer_h + gap)), (round(width * 0.5), car_y - round(height * 0.05))], fill=accent, width=max(2, width // 160))
    _draw_car(draw, width, height, progress, accent)

def _draw_kinetic_words(draw: ImageDraw.ImageDraw, width: int, height: int, text: str, progress: float, accent: tuple[int, int, int]) -> None:
    words = text.split()
    current = words[: max(1, min(len(words), math.ceil(progress * max(1, len(words)))))]
    main = " ".join(current[-5:])
    size = max(28, round(width * (0.105 + 0.025 * math.sin(progress * math.tau * 3))))
    font = _font(size, bold=True)
    wrapped = _wrap(main, max(8, round(width / (size * 0.48))))
    lines = wrapped.splitlines()[:3]
    total_h = 0
    boxes = []
    for line in lines:
        box = draw.textbbox((0, 0), line, font=font)
        boxes.append(box)
        total_h += box[3] - box[1]
    y = height // 2 - total_h // 2
    for line, box in zip(lines, boxes):
        line_w = box[2] - box[0]
        x = (width - line_w) // 2 + round(math.sin(progress * math.tau * 2) * width * 0.025)
        draw.text((x + 3, y + 3), line, font=font, fill=(15, 23, 42))
        draw.text((x, y), line, font=font, fill=(248, 250, 252))
        y += box[3] - box[1] + round(size * 0.18)
    glow = round(width * (0.12 + 0.08 * _ease((progress * 3) % 1)))
    draw.ellipse([width // 2 - glow, round(height * 0.18) - glow, width // 2 + glow, round(height * 0.18) + glow], outline=accent, width=max(2, width // 140))

def _render_frame(job: dict[str, Any], width: int, height: int, frame_index: int, total_frames: int) -> Image.Image:
    mode = job["asset_mode"]
    start, end, accent = PALETTE_BY_MODE.get(mode, PALETTE_BY_MODE["cinematic_ai_video"])
    progress = frame_index / max(1, total_frames - 1)
    # simulated handheld push-in: brighten and offset gradient slightly over time
    image = _gradient(width, height, start, end)
    draw = ImageDraw.Draw(image)

    for i in range(9):
        y = round((i / 8) * height + math.sin(progress * math.tau + i) * height * 0.014)
        draw.line([(0, y), (width, y + round(height * 0.08))], fill=tuple(min(255, c + 16) for c in start), width=max(1, width // 90))

    if mode == "mechanism_animation":
        _draw_mechanism_layers(draw, width, height, progress, accent, twist="teuerste Fehler" in job.get("overlay_text", ""))
    elif mode == "kinetic_text_overlay":
        _draw_kinetic_words(draw, width, height, job["overlay_text"], progress, accent)
    else:
        _draw_car(draw, width, height, progress, accent)
        # local parallax objects / risk tags
        tag_font = _font(max(12, round(width * 0.038)), bold=True)
        for idx, label in enumerate(["-4'000?", "Risiko", "Timing"]):
            p = (progress + idx * 0.18) % 1
            x = round(width * (0.1 + 0.72 * p))
            y = round(height * (0.17 + 0.08 * idx + 0.015 * math.sin(progress * math.tau * 2 + idx)))
            draw.rounded_rectangle([x, y, x + round(width * 0.22), y + round(height * 0.045)], radius=round(width * 0.02), fill=(248, 250, 252), outline=accent, width=max(1, width // 220))
            draw.text((x + round(width * 0.018), y + round(height * 0.01)), label, font=tag_font, fill=(15, 23, 42))

    title_font = _font(max(11, round(width * 0.031)), bold=False)
    draw.text((round(width * 0.06), round(height * 0.045)), f"{job['scene_id']} · {mode.replace('_', ' ')}", font=title_font, fill=(226, 232, 240))
    _draw_caption(draw, job["overlay_text"], width, height, accent, _ease((progress * 4) % 1))
    return image

def local_render_jobs(production_jobs: dict[str, Any]) -> list[dict[str, Any]]:
    """Return only executable local render jobs, excluding text-only LLM refinement."""
    providers = {job["provider"] for job in production_jobs["jobs"]}
    forbidden = providers & PAID_VIDEO_PROVIDERS
    if forbidden:
        raise ValueError(f"Paid video providers are forbidden in local render: {sorted(forbidden)}")
    return [job for job in production_jobs["jobs"] if job["provider"] in LOCAL_RENDER_PROVIDERS]

def _write_frames(render_jobs: list[dict[str, Any]], frame_dir: Path, width: int, height: int, fps: int) -> tuple[int, int]:
    frame_dir.mkdir(parents=True, exist_ok=True)
    frame_number = 0
    motion_events = 0
    for job in render_jobs:
        scene_frames = max(1, round(float(job["duration_seconds"]) * fps))
        motion_events += max(2, math.ceil(float(job["duration_seconds"]) / 2))
        for local_index in range(scene_frames):
            image = _render_frame(job, width, height, local_index, scene_frames)
            image.save(frame_dir / f"frame_{frame_number:06d}.png")
            frame_number += 1
    return frame_number, motion_events

def render_local_motion_video(
    production_jobs: dict[str, Any],
    output_path: Path,
    width: int = 540,
    height: int = 960,
    fps: int = 24,
) -> dict[str, Any]:
    """Render local/free production jobs into a vertical MP4 and metadata JSON."""
    ffmpeg = shutil.which("ffmpeg")
    if not ffmpeg:
        raise RuntimeError("ffmpeg is required to render local motion MP4 files")

    render_jobs = local_render_jobs(production_jobs)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    total_duration = sum(float(job["duration_seconds"]) for job in render_jobs)

    with tempfile.TemporaryDirectory(prefix="autoshorts_local_motion_") as tmp:
        frame_dir = Path(tmp) / "frames"
        frame_count, motion_event_count = _write_frames(render_jobs, frame_dir, width, height, fps)
        command = [
            ffmpeg,
            "-y",
            "-framerate",
            str(fps),
            "-i",
            str(frame_dir / "frame_%06d.png"),
            "-vf",
            "format=yuv420p",
            "-movflags",
            "+faststart",
            str(output_path),
        ]
        completed = subprocess.run(command, check=True, capture_output=True, text=True)

    metadata_path = output_path.with_suffix(".json")
    metadata = {
        "schema_version": "local_motion_render.v1",
        "video_id": production_jobs["video"]["id"],
        "source_schema_version": production_jobs["schema_version"],
        "output_path": str(output_path),
        "width": width,
        "height": height,
        "fps": fps,
        "frame_count": frame_count,
        "scene_count": len(render_jobs),
        "rendered_duration_seconds": int(total_duration) if total_duration.is_integer() else total_duration,
        "motion_event_count": motion_event_count,
        "paid_credit_providers_used": [],
        "budget_policy": production_jobs["budget_policy"],
        "scenes": [
            {
                "scene_id": job["scene_id"],
                "asset_mode": job["asset_mode"],
                "provider": job["provider"],
                "execution_mode": job["execution_mode"],
                "duration_seconds": job["duration_seconds"],
                "motion_directive": job["motion_directive"],
            }
            for job in render_jobs
        ],
        "ffmpeg_stderr_tail": completed.stderr[-2000:],
    }
    metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

    return {
        "output_path": output_path,
        "metadata_path": metadata_path,
        "video_id": production_jobs["video"]["id"],
        "width": width,
        "height": height,
        "fps": fps,
        "frame_count": frame_count,
        "scene_count": len(render_jobs),
        "rendered_duration_seconds": metadata["rendered_duration_seconds"],
        "motion_event_count": motion_event_count,
        "paid_credit_providers_used": [],
    }


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py">
ERROR [275:76] Cannot access attribute "is_integer" for class "Literal[0]"
  Attribute "is_integer" is unknown [reportAttributeAccessIssue] (Pyright)
</diagnostics>


Explanation:
**Fixing typing errors**

I need to fix my typing — maybe I should set `total_duration` to 0.0. It feels really straightforward, but I want to make sure I'm doing it correctly. I'll have to come back to this later and run some tests to ensure everything works smoothly. It’s important for me to double-check that I’ve set everything up right, so I don’t miss anything crucial. Alright, I'll keep this in mind!


Output:
..                                                                       [100%]
2 passed in 0.98s


Explanation:
**Fixing LSP issues**

I need to fix the LSP, and I’m wondering if maybe pyright isn’t included in the tests. I should consider creating a patch. For the total_duration, I’ll use: total_duration: float = sum(..., 0.0). I think it might also be a good idea to create a script for this. It will help ensure everything runs smoothly. I want to get this right, so I'll take my time to sort it out!

patch

    render_jobs = local_render_jobs(production_jobs)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    total_duration = sum(float(job["duration_seconds"]) for job in render_jobs)

    render_jobs = local_render_jobs(production_jobs)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    total_duration: float = sum((float(job["duration_seconds"]) for job in render_jobs), 0.0)


--- a//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
@@ -241,7 +241,7 @@

     render_jobs = local_render_jobs(production_jobs)
     output_path.parent.mkdir(parents=True, exist_ok=True)
-    total_duration = sum(float(job["duration_seconds"]) for job in render_jobs)
+    total_duration: float = sum((float(job["duration_seconds"]) for job in render_jobs), 0.0)

     with tempfile.TemporaryDirectory(prefix="autoshorts_local_motion_") as tmp:
         frame_dir = Path(tmp) / "frames"


"""Render free production job specs with the local motion renderer."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from autoshorts.local_motion_renderer import render_local_motion_video

def _default_output_path(jobs: dict, output_dir: Path) -> Path:
    return output_dir / f"{jobs['video']['id']}.mp4"

def main() -> None:
    parser = argparse.ArgumentParser(description="Render free/local motion MP4 from production jobs.")
    parser.add_argument(
        "--jobs",
        default="data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json",
        help="Path to a free production jobs JSON file.",
    )
    parser.add_argument("--out-dir", default="data/local_motion_renders/mechanism_pilots")
    parser.add_argument("--output", default=None, help="Optional exact MP4 output path.")
    parser.add_argument("--width", type=int, default=540)
    parser.add_argument("--height", type=int, default=960)
    parser.add_argument("--fps", type=int, default=24)
    args = parser.parse_args()

    jobs_path = Path(args.jobs)
    jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
    output_path = Path(args.output) if args.output else _default_output_path(jobs, Path(args.out_dir))
    result = render_local_motion_video(jobs, output_path, width=args.width, height=args.height, fps=args.fps)

    print(f"Rendered local motion video: {result['output_path']}")
    print(f"Metadata: {result['metadata_path']}")
    print(f"Scenes: {result['scene_count']} | duration: {result['rendered_duration_seconds']}s | fps: {result['fps']}")

if __name__ == "__main__":
    main()


Explanation:
**Examining test requirements**

I need to ensure that tests in local_renderer check if "metadata duration matches video duration_ms." If there's a mismatch, the test could fail, but it doesn’t always stem from production jobs. I should fix asset_plan _duration_seconds to return the exact item duration as a float for mechanism pilots. There's an existing test that expects values to be less than or equal to 6 for generic top3, which might break for scene 03 with a duration of 13 seconds. Let's analyze the test_asset_plan line further.


     1|import json
     2|from pathlib import Path
     3|
     4|from autoshorts.asset_plan import build_asset_plan, write_asset_plans
     5|from autoshorts.mechanism_pipeline import build_mechanism_shot_plan
     6|from autoshorts.render_manifest import build_render_manifest
     7|from autoshorts.retention_studio import PILOT_IDEAS, build_mechanism_explainer
     8|
     9|
    10|def _manifest(name: str = "ai-004-stop-asking-ai-to-think-for-you") -> dict:
    11|    path = Path("data/render_manifests/testbatch_002_top3") / f"{name}.json"
    12|    return json.loads(path.read_text(encoding="utf-8"))
    13|
    14|
    15|def test_build_asset_plan_replaces_static_cards_with_moving_asset_modes():
    16|    manifest = _manifest()
    17|
    18|    plan = build_asset_plan(manifest)
    19|
    20|    assert plan["schema_version"] == "asset_plan.v2"
    21|    assert plan["video"]["id"] == "ai-004"
    22|    assert len(plan["scenes"]) == 5
    23|    modes = [scene["asset_mode"] for scene in plan["scenes"]]
    24|    assert "cinematic_ai_video" in modes
    25|    assert "screen_recording_simulation" in modes
    26|    assert "kinetic_text_overlay" in modes
    27|    assert "static_card" not in modes
    28|    assert all(scene["motion_directive"] for scene in plan["scenes"])
    29|    assert all(scene["overlay_text"] for scene in plan["scenes"])
    30|
    31|
    32|def test_cinematic_ai_video_scenes_have_provider_ready_prompts_without_baked_text():
    33|    manifest = _manifest()
    34|
    35|    plan = build_asset_plan(manifest)
    36|
    37|    cinematic = [scene for scene in plan["scenes"] if scene["asset_mode"] == "cinematic_ai_video"]
    38|    assert cinematic
    39|    for scene in cinematic:
    40|        prompt = scene["generation_prompt"]
    41|        assert scene["provider_family"] == "local_free_renderer"
    42|        assert scene["no_text_in_video"] is True
    43|        assert "cinematic-looking" in prompt.lower()
    44|        assert "vertical 9:16" in prompt.lower()
    45|        assert "paid ai-video credits" in prompt.lower()
    46|        assert "no readable text" in scene["negative_prompt"].lower()
    47|        assert scene["duration_seconds"] >= 3
    48|        assert scene["duration_seconds"] <= 6
    49|
    50|
    51|def test_attention_design_asset_plan_uses_broll_and_screen_recording_not_text_slideshow():
    52|    manifest = _manifest("attention-002-the-algorithm-does-not-need-you-happy")
    53|
    54|    plan = build_asset_plan(manifest)
    55|
    56|    modes = [scene["asset_mode"] for scene in plan["scenes"]]
    57|    assert "cinematic_ai_video" in modes
    58|    assert "screen_recording_simulation" in modes
    59|    assert "animated_card" in modes
    60|    assert modes.count("kinetic_text_overlay") <= 2
    61|    assert plan["final_render_strategy"]["static_card_limit"] == 0
    62|
    63|
    64|def test_write_asset_plans_outputs_json_files(tmp_path):
    65|    manifests = [_manifest(), _manifest("attention-002-the-algorithm-does-not-need-you-happy")]
    66|
    67|    paths = write_asset_plans(manifests, tmp_path)
    68|
    69|    assert len(paths) == 2
    70|    assert all(path.suffix == ".json" for path in paths)
    71|    payload = json.loads(paths[0].read_text(encoding="utf-8"))
    72|    assert payload["schema_version"] == "asset_plan.v2"
    73|    assert payload["scenes"][0]["scene_id"] == "scene_01"
    74|
    75|
    76|def test_asset_plan_maps_mechanism_manifest_to_motion_explainer_modes():
    77|    explainer = build_mechanism_explainer(PILOT_IDEAS[0])
    78|    manifest = build_render_manifest(build_mechanism_shot_plan(explainer))
    79|
    80|    plan = build_asset_plan(manifest)
    81|
    82|    modes = [scene["asset_mode"] for scene in plan["scenes"]]
    83|    assert modes == [
    84|        "cinematic_ai_video",
    85|        "cinematic_ai_video",
    86|        "mechanism_animation",
    87|        "mechanism_animation",
    88|        "kinetic_text_overlay",
    89|    ]
    90|    assert plan["final_render_strategy"]["preferred_mix"]["mechanism_animation"] == 2
    91|    mechanism_scene = plan["scenes"][2]
    92|    assert mechanism_scene["provider_family"] == "local_free_renderer"
    93|    assert "Röntgen" in mechanism_scene["generation_prompt"] or "x-ray" in mechanism_scene["generation_prompt"]
    94|


Explanation:
**Evaluating item properties**

I’m thinking that if an item has "start_ms" and "duration_ms," I should just return that exact float. However, there might be an issue where older cinematic formats could break if the values exceed 6. So, I want to take a closer look at scene durations in ai-004—could it be something like 5, 8, or 10? That could help clarify how to approach this without causing any issues.


     1|{
     2|  "schema_version": "render_manifest.v1",
     3|  "video": {
     4|    "id": "hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann",
     5|    "title": "Warum ein billiges Auto teuer sein kann",
     6|    "pillar": "hidden_money_systems",
     7|    "duration_ms": 34000,
     8|    "aspect_ratio": "9:16",
     9|    "resolution": {
    10|      "width": 1080,
    11|      "height": 1920
    12|    },
    13|    "fps": 30
    14|  },
    15|  "timeline": [
    16|    {
    17|      "id": "scene_01",
    18|      "track": "visual",
    19|      "type": "cinematic_ai_video",
    20|      "asset_ref": "placeholder://hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene-01/cinematic_ai_video",
    21|      "start_ms": 0,
    22|      "duration_ms": 1400,
    23|      "text": "Dieser Occasion-Käufer denkt, er spart 4'000 Franken.",
    24|      "visual_direction": "3D car x-ray with price tag layers and risk meters freezes mid-action with warning signal.",
    25|      "visual_motion": "hard zoom, freeze-frame, red signal pulse",
    26|      "sound_cue": "bass hit + camera snap",
    27|      "retention_goal": "Open a visual loop immediately; make the viewer need the hidden explanation.",
    28|      "style": {
    29|        "safe_area_pct": 8,
    30|        "font": "bold_sans",
    31|        "caption_case": "sentence",
    32|        "motion": "push_zoom",
    33|        "contrast": "high",
    34|        "layout": "cinematic_under_caption",
    35|        "no_baked_text": true,
    36|        "pace": "fast_cut"
    37|      }
    38|    },
    39|    {
    40|      "id": "caption_01",
    41|      "track": "captions",
    42|      "type": "kinetic_caption",
    43|      "start_ms": 0,
    44|      "duration_ms": 1000,
    45|      "text": "Dieser Occasion-Käufer denkt, er spart 4'000 Franken.",
    46|      "style": {
    47|        "safe_area_pct": 8,
    48|        "position": "lower_third",
    49|        "max_chars_per_line": 28,
    50|        "highlight_keywords": true
    51|      }
    52|    },
    53|    {
    54|      "id": "caption_02",
    55|      "track": "captions",
    56|      "type": "kinetic_caption",
    57|      "start_ms": 1000,
    58|      "duration_ms": 4000,
    59|      "text": "Das sieht harmlos aus, bis du den Mechanismus dahinter siehst.",
    60|      "style": {
    61|        "safe_area_pct": 8,
    62|        "position": "lower_third",
    63|        "max_chars_per_line": 28,
    64|        "highlight_keywords": true
    65|      }
    66|    },
    67|    {
    68|      "id": "scene_02",
    69|      "track": "visual",
    70|      "type": "cinematic_ai_video",
    71|      "asset_ref": "placeholder://hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene-02/cinematic_ai_video",
    72|      "start_ms": 1400,
    73|      "duration_ms": 3600,
    74|      "text": "Das sieht harmlos aus, bis du den Mechanismus dahinter siehst.",
    75|      "visual_direction": "surface layer peels away to reveal the hidden system underneath.",
    76|      "visual_motion": "layer peel + parallax push",
    77|      "sound_cue": "whoosh + low tension tick",
    78|      "retention_goal": "Make the surface-level interpretation feel incomplete.",
    79|      "style": {
    80|        "safe_area_pct": 8,
    81|

Use offset=81 to continue reading (showing 1-80 of 255 lines)


def test_mechanism_free_production_jobs_preserve_total_video_duration():
    jobs = build_free_production_jobs(_asset_plan())
    local_jobs = [job for job in jobs["jobs"] if job["provider"] in {"local_renderer", "local_ffmpeg"}]

    assert sum(job["duration_seconds"] for job in local_jobs) == jobs["video"]["duration_ms"] / 1000

def test_write_free_production_jobs_outputs_json(tmp_path):
    paths = write_free_production_jobs([_asset_plan()], tmp_path)

    assert len(paths) == 1
    payload = json.loads(paths[0].read_text(encoding="utf-8"))
    assert payload["schema_version"] == "free_production_jobs.v1"
    assert payload["video"]["id"] == "hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann"


--- a//home/agent/jarvis_runtime/AutoShortsBot/tests/test_free_production_jobs.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/tests/test_free_production_jobs.py
@@ -48,6 +48,13 @@
     assert all("video generation" not in job["free_workflow"].lower() for job in llm_jobs)

+def test_mechanism_free_production_jobs_preserve_total_video_duration():
+    jobs = build_free_production_jobs(_asset_plan())
+    local_jobs = [job for job in jobs["jobs"] if job["provider"] in {"local_renderer", "local_ffmpeg"}]
+
+    assert sum(job["duration_seconds"] for job in local_jobs) == jobs["video"]["duration_ms"] / 1000
+
+
 def test_write_free_production_jobs_outputs_json(tmp_path):
     paths = write_free_production_jobs([_asset_plan()], tmp_path)


terminal

Output:
F                                                                        [100%]
=================================== FAILURES ===================================
______ test_mechanism_free_production_jobs_preserve_total_video_duration _______

    def test_mechanism_free_production_jobs_preserve_total_video_duration():
        jobs = build_free_production_jobs(_asset_plan())
        local_jobs = [job for job in jobs["jobs"] if job["provider"] in {"local_renderer", "local_ffmpeg"}]

>       assert sum(job["duration_seconds"] for job in local_jobs) == jobs["video"]["duration_ms"] / 1000
E       assert 25 == (34000 / 1000)
E        +  where 25 = sum(<generator object test_mechanism_free_production_jobs_preserve_total_video_duration.<locals>.<genexpr> at 0x7d58bb726330>)

tests/test_free_production_jobs.py:55: AssertionError
=========================== short test summary info ============================
FAILED tests/test_free_production_jobs.py::test_mechanism_free_production_jobs_preserve_total_video_duration
1 failed in 0.05s


Explanation:
**Fixing asset_plan function**

I need to adjust the asset_plan function because _duration_seconds is currently rounded and capped. I think preserving the exact values for hidden_money and mechanism style is essential. I also wonder if a function should accept an item only, potentially changing its signature with pillar.

If an item's type is either "mechanism_animation," "cinematic_ai_video," or "kinetic_text_overlay," it should return the exact duration in seconds. Otherwise, it'll cap it. I need to make sure it can return both int and float, so I'll conduct tests to verify everything works smoothly.

patch

def _duration_seconds(item: dict[str, Any]) -> int | float:
    seconds = int(item["duration_ms"]) / 1000
    if item.get("type") in {"cinematic_ai_video", "mechanism_animation", "kinetic_text_overlay"}:
        return int(seconds) if seconds.is_integer() else seconds
    return max(3, min(6, round(seconds)))

def _duration_seconds(item: dict[str, Any]) -> int:
    return max(3, min(6, round(int(item["duration_ms"]) / 1000)))


--- a//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/asset_plan.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/asset_plan.py
@@ -49,8 +49,11 @@
     return [item for item in manifest["timeline"] if item["track"] == "visual"]

-def _duration_seconds(item: dict[str, Any]) -> int:
-    return max(3, min(6, round(int(item["duration_ms"]) / 1000)))
+def _duration_seconds(item: dict[str, Any]) -> int | float:
+    seconds = int(item["duration_ms"]) / 1000
+    if item.get("type") in {"cinematic_ai_video", "mechanism_animation", "kinetic_text_overlay"}:
+        return int(seconds) if seconds.is_integer() else seconds
+    return max(3, min(6, round(seconds)))

 def _asset_mode(item: dict[str, Any], pillar: str) -> str:


terminal

python scripts/generate_asset_plans.py --manifest data/render_manifests/mechanism_pilots --out data/asset_plans/mechanism_pilots && python scripts/generate_free_production_jobs.py --asset-plan data/asset_plans/mechanism_pilots --out data/free_production_jobs/mechanism_pilots && pytest tests/test_free_production_jobs.py::test_mechanism_free_production_jobs_preserve_total_video_duration tests/test_local_motion_renderer.py -q


Output:
Generated 1 asset plans in data/asset_plans/mechanism_pilots
data/asset_plans/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json
Generated 1 free production job specs in data/free_production_jobs/mechanism_pilots
data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json
...                                                                      [100%]
3 passed in 1.33s


Explanation:
**Calculating test duration**

I need a local test now, and I expect the duration to be 34 seconds since the JSON has been updated, which is good. I should render the actual pilot and maybe use a resolution of 540x960 at 12 fps to save some processing time. The script defaults to 24 fps, which would mean 816 frames for 34 seconds, but I want to keep it under 600 frames. Setting it to 15 fps seems decent, and I need to produce an MP4.

python scripts/render_free_local_motion.py --jobs data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json --out-dir data/local_motion_renders/mechanism_pilots --width 540 --height 960 --fps 15


Output:
Rendered local motion video: data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4
Metadata: data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json
Scenes: 5 | duration: 34s | fps: 15


Explanation:
**Verifying media with tools**

I need to check the media details using ffprobe. I should run some full tests. It might be a good idea to grab a screenshot as well, possibly using vision tools. Then, I think I should also run a git status command to see the current state of the repository. It's all about being thorough and making sure everything is in order before moving forward!

ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate,duration -of default=nw=1:nk=1 data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4


Output:
.................................................                        [100%]
49 passed in 2.19s


Output:
540
960
15/1
34.000000


     1|{
     2|  "schema_version": "local_motion_render.v1",
     3|  "video_id": "hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann",
     4|  "source_schema_version": "free_production_jobs.v1",
     5|  "output_path": "data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4",
     6|  "width": 540,
     7|  "height": 960,
     8|  "fps": 15,
     9|  "frame_count": 510,
    10|  "scene_count": 5,
    11|  "rendered_duration_seconds": 34,
    12|  "motion_event_count": 19,
    13|  "paid_credit_providers_used": [],
    14|  "budget_policy": {
    15|    "paid_video_credits_allowed": false,
    16|    "allowed_providers": [
    17|      "local_renderer",
    18|      "local_ffmpeg",
    19|      "free_nvidia_llm"
    20|    ],
    21|    "nvidia_llm_scope": "text_refinement_only"
    22|  },
    23|  "scenes": [
    24|    {
    25|      "scene_id": "scene_01",
    26|      "asset_mode": "cinematic_ai_video",
    27|      "provider": "local_renderer",
    28|      "execution_mode": "procedural_motion",
    29|      "duration_seconds": 1.4,
    30|      "motion_directive": "slow handheld push-in, subtle parallax, natural micro-movements"
    31|    },
    32|    {
    33|      "scene_id": "scene_02",
    34|      "asset_mode": "cinematic_ai_video",
    35|      "provider": "local_renderer",
    36|      "execution_mode": "procedural_motion",
    37|      "duration_seconds": 3.6,
    38|      "motion_directive": "slow handheld push-in, subtle parallax, natural micro-movements"
    39|    },
    40|    {
    41|      "scene_id": "scene_03",
    42|      "asset_mode": "mechanism_animation",
    43|      "provider": "local_renderer",
    44|      "execution_mode": "mechanism_reveal_animation",
    45|      "duration_seconds": 13,
    46|      "motion_directive": "large kinetic caption words pop and slide with beat-synced emphasis"
    47|    },
    48|    {
    49|      "scene_id": "scene_04",
    50|      "asset_mode": "mechanism_animation",
    51|      "provider": "local_renderer",
    52|      "execution_mode": "mechanism_reveal_animation",
    53|      "duration_seconds": 10,
    54|      "motion_directive": "large kinetic caption words pop and slide with beat-synced emphasis"
    55|    },
    56|    {
    57|      "scene_id": "scene_05",
    58|      "asset_mode": "kinetic_text_overlay",
    59|      "provider": "local_ffmpeg",
    60|      "execution_mode": "kinetic_typography",
    61|      "duration_seconds": 6,
    62|      "motion_directive": "large kinetic caption words pop and slide with beat-synced emphasis"
    63|    }
    64|  ],
    65|  "ffmpeg_stderr_tail": ".0 size=     256kB time=00:00:07.80 bitrate= 268.9kbits/s speed=7.67x    \nframe=  225 fps=148 q=27.0 size=     512kB time=00:00:14.86 bitrate= 282.2kbits/s speed= 9.8x    \nframe=  334 fps=165 q=27.0 size=    1024kB time=00:00:22.13 bitrate= 379.0kbits/s speed=  11x    \nframe=  436 fps=173 q=27.0 size=    1280kB time=00:00:28.93 bitrate= 362.4kbits/s speed=11.5x    \n[mp4 @ 0x6412eb627f00] Starting second pass: moving the moov atom to the beginning of the file\n[out#0/mp4 @ 0x6412eb45f200] video:1717kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.331028%\nframe=  510 fps=179 q=-1.0 Lsize=    1723kB time=00:00:33.80 bitrate= 417.5kbits/s speed=11.8x    \n[libx264 @ 0x6412eb677600] frame I:3     Avg QP:15.41  size: 26828\n[libx264 @ 0x6412eb677600] frame P:305   Avg QP:18.84  size:  4516\n[libx264 @ 0x6412eb677600] frame B:202   Avg QP:26.34  size:  1483\n[libx264 @ 0x6412eb677600] consecutive B-frames: 35.3% 32.2% 10.6% 22.0%\n[libx264 @ 0x6412eb677600] mb I  I16..4: 48.2% 24.8% 27.0%\n[libx264 @ 0x6412eb677600] mb P  I16..4:  3.2%  1.5%  1.2%  P16..4: 11.8%  4.3%  2.5%  0.0%  0.0%    skip:75.4%\n[libx264 @ 0x6412eb677600] mb B  I16..4:  0.5%  0.2%  0.2%  B16..8: 18.3%  2.7%  1.0%  direct: 0.5%  skip:76.7%  L0:51.6% L1:46.6% BI: 1.8%\n[libx264 @ 0x6412eb677600] 8x8 transform intra:25.7% inter:22.9%\n[libx264 @ 0x6412eb677600] coded y,uvDC,uvAC intra: 14.1% 33.4% 27.7% inter: 2.4% 3.8% 2.4%\n[libx264 @ 0x6412eb677600] i16 v,h,dc,p: 24% 74%  1%  0%\n[libx264 @ 0x6412eb677600] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu:  7% 31% 61%  0%  0%  0%  0%  0%  0%\n[libx264 @ 0x6412eb677600] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 36% 25% 20%  2%  3%  3%  5%  3%  3%\n[libx264 @ 0x6412eb677600] i8c dc,h,v,p: 25% 59% 15%  1%\n[libx264 @ 0x6412eb677600] Weighted P-Frames: Y:0.0% UV:0.0%\n[libx264 @ 0x6412eb677600] ref P L0: 67.3%  3.6% 17.6% 11.5%\n[libx264 @ 0x6412eb677600] ref B L0: 78.5% 18.5%  3.0%\n[libx264 @ 0x6412eb677600] ref B L1: 97.... [truncated]
    66|}
    67|


Explanation:
**Considering screenshot extraction**

I need to create a vision preview screenshot. To do this, I might use the terminal to extract a frame. It seems like using ffmpeg could be a good choice for generating the screenshot and working with vision. I’m making sure I have the right approach, since it's important to capture the frame correctly. So, let’s see how it all comes together!

mkdir -p /tmp/autoshorts_preview_check && ffmpeg -y -ss 00:00:08 -i data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4 -frames:v 1 /tmp/autoshorts_preview_check/frame_8s.jpg >/dev/null 2>&1 && file /tmp/autoshorts_preview_check/frame_8s.jpg


Output: /tmp/autoshorts_preview_check/frame_8s.jpg: JPEG image data, baseline, precision 8, 540x960, components 3


Question: Kurz prüfen: Ist dies ein vertikaler Mechanism-Explainer-Frame mit sichtbaren animierten/layerartigen Elementen und lesbarer Caption? Gibt es auffällige Renderingfehler?


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

Question: Kurz prüfen: Ist dies ein vertikaler Mechanism-Explainer-Frame mit sichtbaren animierten/layerartigen Elementen und lesbarer Caption? Gibt es auffällige Renderingfehler?


patch

from autoshorts.local_motion_renderer import _caption_layout, local_render_jobs, render_local_motion_video

from autoshorts.local_motion_renderer import local_render_jobs, render_local_motion_video


--- a//home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py
@@ -1,7 +1,7 @@
 import json
 from pathlib import Path

-from autoshorts.local_motion_renderer import local_render_jobs, render_local_motion_video
+from autoshorts.local_motion_renderer import _caption_layout, local_render_jobs, render_local_motion_video

 FIXTURE = Path("data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json")

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py">
ERROR [4:46] "_caption_layout" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>


def test_caption_layout_keeps_long_german_lines_inside_safe_area():
    layout = _caption_layout(
        "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",
        width=180,
        height=320,
        pulse=0.7,
    )

    assert layout["box"][0] >= 0
    assert layout["box"][2] <= 180
    assert max(line["width"] for line in layout["lines"]) <= layout["text_max_width"]
    assert len(layout["lines"]) <= 4

def test_local_render_jobs_filters_out_text_only_nvidia_refinement_jobs():


--- a//home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py
@@ -9,6 +9,20 @@

 def _jobs() -> dict:
     return json.loads(FIXTURE.read_text(encoding="utf-8"))
+
+
+def test_caption_layout_keeps_long_german_lines_inside_safe_area():
+    layout = _caption_layout(
+        "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",
+        width=180,
+        height=320,
+        pulse=0.7,
+    )
+
+    assert layout["box"][0] >= 0
+    assert layout["box"][2] <= 180
+    assert max(line["width"] for line in layout["lines"]) <= layout["text_max_width"]
+    assert len(layout["lines"]) <= 4

 def test_local_render_jobs_filters_out_text_only_nvidia_refinement_jobs():


terminal

Output:
ERROR: found no collectors for /home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py::test_caption_layout_keeps_long_german_lines_inside_safe_area

==================================== ERRORS ====================================
_____________ ERROR collecting tests/test_local_motion_renderer.py _____________
ImportError while importing test module '/home/agent/jarvis_runtime/AutoShortsBot/tests/test_local_motion_renderer.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_local_motion_renderer.py:4: in <module>
    from autoshorts.local_motion_renderer import _caption_layout, local_render_jobs, render_local_motion_video
E   ImportError: cannot import name '_caption_layout' from 'autoshorts.local_motion_renderer' (/home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py)
=========================== short test summary info ============================
ERROR tests/test_local_motion_renderer.py
1 error in 0.06s


Explanation:
**Implementing caption layout**

I'm thinking that I need to implement _caption_layout and replace _draw_caption. This sounds like a straightforward task, but I'll need to make sure that the new layout aligns well with the overall design. I want to ensure that everything looks cohesive, so I may have to test it out first. I wonder if there are any edge cases I should consider. Let's see how this goes!

patch

def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
    size = max(22, round(width * (0.062 + 0.014 * pulse)))
    font = _font(size, bold=True)
    wrapped = _wrap(text, max(12, round(width / (size * 0.52))))
    lines = wrapped.splitlines()[:3]
    line_boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
    line_heights = [box[3] - box[1] for box in line_boxes]
    spacing = max(4, round(size * 0.22))
    block_height = sum(line_heights) + spacing * max(0, len(lines) - 1)
    pad = round(width * 0.045)
    y = height - round(height * 0.21) - block_height // 2
    box_top = y - pad
    box_bottom = y + block_height + pad
    draw.rounded_rectangle(
        [round(width * 0.06), box_top, round(width * 0.94), box_bottom],
        radius=round(width * 0.035),
        fill=(255, 255, 255),
        outline=accent,
        width=max(2, width // 180),
    )
    for line, box, line_height in zip(lines, line_boxes, line_heights):
        line_width = box[2] - box[0]
        draw.text(((width - line_width) // 2, y), line, font=font, fill=(15, 23, 42))
        y += line_height + spacing

def _caption_layout(text: str, width: int, height: int, pulse: float) -> dict[str, Any]:
    """Calculate a caption layout that stays inside the vertical safe area."""
    text_max_width = round(width * 0.78)
    max_box_width = round(width * 0.88)
    max_block_height = round(height * 0.21)
    pad = max(8, round(width * 0.035))
    preferred_size = max(18, round(width * (0.06 + 0.012 * pulse)))
    min_size = max(10, round(width * 0.038))

    scratch = Image.new("RGB", (width, height))
    draw = ImageDraw.Draw(scratch)
    for size in range(preferred_size, min_size - 1, -1):
        font = _font(size, bold=True)
        # Width-based wrapping alone is too optimistic for German compound words;
        # verify actual pixel width and shrink/re-wrap until it fits.
        wrap_chars = max(8, round(text_max_width / (size * 0.5)))
        wrapped = _wrap(text, wrap_chars)
        raw_lines = wrapped.splitlines()
        if len(raw_lines) > 4:
            raw_lines = raw_lines[:3] + [" ".join(raw_lines[3:])]
        spacing = max(4, round(size * 0.20))
        lines = []
        too_wide = False
        for line in raw_lines:
            box = draw.textbbox((0, 0), line, font=font)
            line_width = box[2] - box[0]
            line_height = box[3] - box[1]
            if line_width > text_max_width:
                too_wide = True
                break
            lines.append({"text": line, "width": line_width, "height": line_height})
        block_height = sum(line["height"] for line in lines) + spacing * max(0, len(lines) - 1)
        if lines and not too_wide and block_height <= max_block_height:
            y = height - round(height * 0.205) - block_height // 2
            box = [
                round((width - max_box_width) / 2),
                y - pad,
                round((width + max_box_width) / 2),
                y + block_height + pad,
            ]
            return {
                "font": font,
                "font_size": size,
                "lines": lines,
                "spacing": spacing,
                "box": box,
                "text_max_width": text_max_width,
                "start_y": y,
            }

    font = _font(min_size, bold=True)
    line = text[:42].rstrip() + ("…" if len(text) > 42 else "")
    box = draw.textbbox((0, 0), line, font=font)
    return {
        "font": font,
        "font_size": min_size,
        "lines": [{"text": line, "width": box[2] - box[0], "height": box[3] - box[1]}],
        "spacing": max(4, round(min_size * 0.20)),
        "box": [round(width * 0.06), round(height * 0.72), round(width * 0.94), round(height * 0.84)],
        "text_max_width": text_max_width,
        "start_y": round(height * 0.76),
    }

def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
    layout = _caption_layout(text, width, height, pulse)
    draw.rounded_rectangle(
        layout["box"],
        radius=round(width * 0.035),
        fill=(255, 255, 255),
        outline=accent,
        width=max(2, width // 180),
    )
    y = layout["start_y"]
    for line in layout["lines"]:
        x = (width - line["width"]) // 2
        draw.text((x, y), line["text"], font=layout["font"], fill=(15, 23, 42))
        y += line["height"] + layout["spacing"]


--- a//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
@@ -62,30 +62,84 @@
     return 0.5 - math.cos(progress * math.pi) / 2

+def _caption_layout(text: str, width: int, height: int, pulse: float) -> dict[str, Any]:
+    """Calculate a caption layout that stays inside the vertical safe area."""
+    text_max_width = round(width * 0.78)
+    max_box_width = round(width * 0.88)
+    max_block_height = round(height * 0.21)
+    pad = max(8, round(width * 0.035))
+    preferred_size = max(18, round(width * (0.06 + 0.012 * pulse)))
+    min_size = max(10, round(width * 0.038))
+
+    scratch = Image.new("RGB", (width, height))
+    draw = ImageDraw.Draw(scratch)
+    for size in range(preferred_size, min_size - 1, -1):
+        font = _font(size, bold=True)
+        # Width-based wrapping alone is too optimistic for German compound words;
+        # verify actual pixel width and shrink/re-wrap until it fits.
+        wrap_chars = max(8, round(text_max_width / (size * 0.5)))
+        wrapped = _wrap(text, wrap_chars)
+        raw_lines = wrapped.splitlines()
+        if len(raw_lines) > 4:
+            raw_lines = raw_lines[:3] + [" ".join(raw_lines[3:])]
+        spacing = max(4, round(size * 0.20))
+        lines = []
+        too_wide = False
+        for line in raw_lines:
+            box = draw.textbbox((0, 0), line, font=font)
+            line_width = box[2] - box[0]
+            line_height = box[3] - box[1]
+            if line_width > text_max_width:
+                too_wide = True
+                break
+            lines.append({"text": line, "width": line_width, "height": line_height})
+        block_height = sum(line["height"] for line in lines) + spacing * max(0, len(lines) - 1)
+        if lines and not too_wide and block_height <= max_block_height:
+            y = height - round(height * 0.205) - block_height // 2
+            box = [
+                round((width - max_box_width) / 2),
+                y - pad,
+                round((width + max_box_width) / 2),
+                y + block_height + pad,
+            ]
+            return {
+                "font": font,
+                "font_size": size,
+                "lines": lines,
+                "spacing": spacing,
+                "box": box,
+                "text_max_width": text_max_width,
+                "start_y": y,
+            }
+
+    font = _font(min_size, bold=True)
+    line = text[:42].rstrip() + ("…" if len(text) > 42 else "")
+    box = draw.textbbox((0, 0), line, font=font)
+    return {
+        "font": font,
+        "font_size": min_size,
+        "lines": [{"text": line, "width": box[2] - box[0], "height": box[3] - box[1]}],
+        "spacing": max(4, round(min_size * 0.20)),
+        "box": [round(width * 0.06), round(height * 0.72), round(width * 0.94), round(height * 0.84)],
+        "text_max_width": text_max_width,
+        "start_y": round(height * 0.76),
+    }
+
+
 def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
-    size = max(22, round(width * (0.062 + 0.014 * pulse)))
-    font = _font(size, bold=True)
-    wrapped = _wrap(text, max(12, round(width / (size * 0.52))))
-    lines = wrapped.splitlines()[:3]
-    line_boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
-    line_heights = [box[3] - box[1] for box in line_boxes]
-    spacing = max(4, round(size * 0.22))
-    block_height = sum(line_heights) + spacing * max(0, len(lines) - 1)
-    pad = round(width * 0.045)
-    y = height - round(height * 0.21) - block_height // 2
-    box_top = y - pad
-    box_bottom = y + block_height + pad
+    layout = _caption_layout(text, width, height, pulse)
     draw.rounded_rectangle(
-        [round(width * 0.06), box_top, round(width * 0.94), box_bottom],
+        layout["box"],
         radius=round(width * 0.035),
         fill=(255, 255, 255),
         outline=accent,
         width=max(2, width // 180),
     )
-    for line, box, line_height in zip(lines, line_boxes, line_heights):
-        line_width = box[2] - box[0]
-        draw.text(((width - line_width) // 2, y), line, font=font, fill=(15, 23, 42))
-        y += line_height + spacing
+    y = layout["start_y"]
+    for line in layout["lines"]:
+        x = (width - line["width"]) // 2
+        draw.text((x, y), line["text"], font=layout["font"], fill=(15, 23, 42))
+        y += line["height"] + layout["spacing"]

 def _draw_car(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int]) -> None:


terminal

Output:
F..                                                                      [100%]
=================================== FAILURES ===================================
_________ test_caption_layout_keeps_long_german_lines_inside_safe_area _________

    def test_caption_layout_keeps_long_german_lines_inside_safe_area():
        layout = _caption_layout(
            "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",
            width=180,
            height=320,
            pulse=0.7,
        )

        assert layout["box"][0] >= 0
        assert layout["box"][2] <= 180
>       assert max(line["width"] for line in layout["lines"]) <= layout["text_max_width"]
E       assert 251 <= 140
E        +  where 251 = max(<generator object test_caption_layout_keeps_long_german_lines_inside_safe_area.<locals>.<genexpr> at 0x775fedf160c0>)

tests/test_local_motion_renderer.py:24: AssertionError
=========================== short test summary info ============================
FAILED tests/test_local_motion_renderer.py::test_caption_layout_keeps_long_german_lines_inside_safe_area
1 failed, 2 passed in 1.72s


Explanation:
**Truncating fallback line**

I'm realizing that the fallback line is too long. I need to make sure it fits, so I'll have to truncate it. To do this efficiently, I’ll implement a helper function that uses a loop until it meets the desired length. There’s probably a clean way to handle this! I want to ensure it works well to avoid any errors or issues that could arise. Alright, let's get started on making that helper!

patch

    font = _font(min_size, bold=True)
    line = text
    suffix = "…" if len(text) > 1 else ""
    while line:
        candidate = line.rstrip() + suffix
        box = draw.textbbox((0, 0), candidate, font=font)
        if box[2] - box[0] <= text_max_width:
            break
        line = line[:-1]
    candidate = (line.rstrip() + suffix) if line else "…"
    box = draw.textbbox((0, 0), candidate, font=font)
    return {
        "font": font,
        "font_size": min_size,
        "lines": [{"text": candidate, "width": box[2] - box[0], "height": box[3] - box[1]}],

    font = _font(min_size, bold=True)
    line = text[:42].rstrip() + ("…" if len(text) > 42 else "")
    box = draw.textbbox((0, 0), line, font=font)
    return {
        "font": font,
        "font_size": min_size,
        "lines": [{"text": line, "width": box[2] - box[0], "height": box[3] - box[1]}],


--- a//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/local_motion_renderer.py
@@ -113,12 +113,20 @@
             }

     font = _font(min_size, bold=True)
-    line = text[:42].rstrip() + ("…" if len(text) > 42 else "")
-    box = draw.textbbox((0, 0), line, font=font)
+    line = text
+    suffix = "…" if len(text) > 1 else ""
+    while line:
+        candidate = line.rstrip() + suffix
+        box = draw.textbbox((0, 0), candidate, font=font)
+        if box[2] - box[0] <= text_max_width:
+            break
+        line = line[:-1]
+    candidate = (line.rstrip() + suffix) if line else "…"
+    box = draw.textbbox((0, 0), candidate, font=font)
     return {
         "font": font,
         "font_size": min_size,
-        "lines": [{"text": line, "width": box[2] - box[0], "height": box[3] - box[1]}],
+        "lines": [{"text": candidate, "width": box[2] - box[0], "height": box[3] - box[1]}],
         "spacing": max(4, round(min_size * 0.20)),
         "box": [round(width * 0.06), round(height * 0.72), round(width * 0.94), round(height * 0.84)],
         "text_max_width": text_max_width,


terminal

Output:
...                                                                      [100%]
3 passed in 2.50s


Explanation:
**Rerendering adjustments**

I need to think about how to adjust the rendering. Maybe I should set the caption to display fully at 540 instead of truncating it. If the layout doesn’t fit, there might be issues with the lines. It seems like I could tweak it to improve the overall presentation. I’ll go ahead and try rendering it again with these modifications in mind!

python scripts/render_free_local_motion.py --jobs data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json --out-dir data/local_motion_renders/mechanism_pilots --width 540 --height 960 --fps 15 && ffmpeg -y -ss 00:00:08 -i data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4 -frames:v 1 /tmp/autoshorts_preview_check/frame_8s_fixed.jpg >/dev/null 2>&1


Output:
Rendered local motion video: data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4
Metadata: data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json
Scenes: 5 | duration: 34s | fps: 15


Question: Prüfe den gerenderten Frame nach dem Caption-Fix: Ist die Caption vollständig innerhalb des weißen Kastens und des Bildes? Sind Mechanism-Layer/Auto erkennbar? Kurze Qualitätsdiagnose.


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

Question: Prüfe den gerenderten Frame nach dem Caption-Fix: Ist die Caption vollständig innerhalb des weißen Kastens und des Bildes? Sind Mechanism-Layer/Auto erkennbar? Kurze Qualitätsdiagnose.


"voiceover": "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",

"mechanism": "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",

"overlay_text": "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und",

"text": "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und",

"text": "Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und Liquidität.",


     1|"""Renderer manifest generation for retention-led AutoShortsBot videos."""
     2|
     3|from __future__ import annotations
     4|
     5|import json
     6|from pathlib import Path
     7|from typing import Any
     8|
     9|from autoshorts.script_drafts import _slug
    10|
    11|RESOLUTION = {"width": 1080, "height": 1920}
    12|ASPECT_RATIO = "9:16"
    13|FPS = 30
    14|
    15|
    16|ASSET_REQUIREMENT_HINTS = {
    17|    "kinetic_text": "Generated by renderer from text and style instructions.",
    18|    "screen_recording": "Needs captured or synthesized vertical screen recording; avoid fake unreadable UI text.",
    19|    "card_animation": "Generated by renderer from concise text cards; animate one idea per beat.",
    20|    "stock_broll": "Needs human/phone/context B-roll; avoid generic corporate stock feel.",
    21|    "generated_background": "Optional abstract background only; must not become the main content.",
    22|    "cinematic_ai_video": "Needs photorealistic vertical AI-video clip; no baked text, logos, or watermarks.",
    23|    "mechanism_animation": "Needs renderer-generated mechanism/layer animation with visible movement every 1-2 seconds.",
    24|    "kinetic_text_overlay": "Generated by renderer as large beat-synced caption overlays, not paragraph cards.",
    25|}
    26|
    27|
    28|def _seconds_to_ms(value: int | float) -> int:
    29|    return round(float(value) * 1000)
    30|
    31|
    32|def _duration_ms(start: int | float, end: int | float) -> int:
    33|    return max(500, _seconds_to_ms(end) - _seconds_to_ms(start))
    34|
    35|
    36|def _placeholder(asset_type: str, video_id: str, scene: int) -> str:
    37|    return f"placeholder://{video_id}/scene-{scene:02d}/{asset_type}"
    38|
    39|
    40|def _visual_style(scene: dict[str, Any]) -> dict[str, Any]:
    41|    base = {
    42|        "safe_area_pct": 8,
    43|        "font": "bold_sans",
    44|        "caption_case": "sentence",
    45|        "motion": "push_zoom" if scene["scene"] == 1 else "cut_or_slide",
    46|        "contrast": "high",
    47|    }
    48|    if scene["asset_type"] == "card_animation":
    49|        base.update({"layout": "minimal_cards", "max_words_on_screen": 18})
    50|    elif scene["asset_type"] == "screen_recording":
    51|        base.update({"layout": "screen_plus_caption", "blur_sensitive_ui": True})
    52|    elif scene["asset_type"] == "stock_broll":
    53|        base.update({"layout": "broll_under_caption", "pace": "fast_cut"})
    54|    elif scene["asset_type"] == "cinematic_ai_video":
    55|        base.update({"layout": "cinematic_under_caption", "no_baked_text": True, "pace": "fast_cut"})
    56|    elif scene["asset_type"] == "mechanism_animation":
    57|        base.update({"layout": "xray_layer_reveal", "max_words_on_screen": 10, "layered_motion": True})
    58|    elif scene["asset_type"] == "kinetic_text_overlay":
    59|        base.update({"layout": "kinetic_full_frame", "max_words_on_screen": 12, "beat_synced": True})
    60|    return base
    61|
    62|
    63|def _visual_timeline_items(plan: dict[str, Any]) -> list[dict[str, Any]]:
    64|    video_id = plan["id"]
    65|    items: list[dict[str, Any]] = []
    66|    for scene in plan["scenes"]:
    67|        items.append(
    68|            {
    69|                "id": f"scene_{scene['scene']:02d}",
    70|                "track": "visual",
    71|                "type": scene["asset_type"],
    72|                "asset_ref": _placeholder(scene["asset_type"], video_id, scene["scene"]),
    73|                "start_ms": _seconds_to_ms(scene["start"]),
    74|                "duration_ms": _duration_ms(scene["start"], scene["end"]),
    75|                "text": scene["on_screen_text"],
    76|                "visual_direction": scene["visual_direction"],
    77|                "visual_motion": scene.get("visual_motion", "cut_or_slide"),
    78|                "sound_cue": scene.get("sound_cue", "none"),
    79|                "retention_goal": scene["retention_goal"],
    80|                "style": _visual_style(scene),
    81|            }
    82|        )
    83|    return items
    84|
    85|
    86|def _caption_timeline_items(plan: dict[str, Any]) -> list[dict[str, Any]]:
    87|    beats = plan.get("caption_beats", [])
    88|    total_ms = int(plan["estimated_seconds"]) * 1000
    89|    items: list[dict[str, Any]] = []
    90|    for index, beat in enumerate(beats):
    91|        start_ms = _seconds_to_ms(beat["start"])
    92|        next_start_ms = _seconds_to_ms(beats[index + 1]["start"]) if index + 1 < len(beats) else total_ms
    93|        items.append(
    94|            {
    95|                "id": f"caption_{index + 1:02d}",
    96|                "track": "captions",
    97|                "type": "kinetic_caption",
    98|                "start_ms": start_ms,
    99|                "duration_ms": max(750, next_start_ms - start_ms),
   100|                "text": beat["text"],
   101|                "style": {
   102|                    "safe_area_pct": 8,
   103|                    "position": "lower_third",
   104|                    "max_chars_per_line": 28,
   105|                    "highlight_keywords": True,
   106|                },
   107|            }
   108|        )
   109|    return items
   110|
   111|
   112|def _asset_requirements(plan: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
   113|    requirements: dict[str, list[dict[str, Any]]] = {key: [] for key in ASSET_REQUIREMENT_HINTS}
   114|    for scene in plan["scenes"]:
   115|        asset_type = scene["asset_type"]
   116|        requirements.setdefault(asset_type, []).append(
   117|            {
   118|                "scene": scene["scene"],
   119|                "asset_ref": _placeholder(asset_type, plan["id"], scene["scene"]),
   120|                "brief": scene["visual_direction"],
   121|                "hint": ASSET_REQUIREMENT_HINTS.get(asset_type, "Provide asset matching the visual direction."),
   122|            }
   123|        )
   124|    return requirements
   125|
   126|
   127|def build_render_manifest(plan: dict[str, Any]) -> dict[str, Any]:
   128|    """Convert a human shot plan into a structured renderer contract."""
   129|    timeline = _visual_timeline_items(plan) + _caption_timeline_items(plan)
   130|    track_order = {"visual": 0, "captions": 1}
   131|    timeline.sort(key=lambda item: (item["start_ms"], track_order.get(item["track"], 99), item["id"]))
   132|    return {
   133|        "schema_version": "render_manifest.v1",
   134|        "video": {
   135|            "id": plan["id"],
   136|            "title": plan["title"],
   137|            "pillar": plan["pillar"],
   138|            "duration_ms": int(plan["estimated_seconds"]) * 1000,
   139|            "aspect_ratio": ASPECT_RATIO,
   140|            "resolution": RESOLUTION,
   141|            "fps": FPS,
   142|        },
   143|        "timeline": timeline,
   144|        "asset_requirements": _asset_requirements(plan),
   145|        "render_constraints": {
   146|            "avoid_static_slideshow": True,
   147|            "pattern_interrupt_every_seconds": 3,
   148|            "visual_change_every_seconds": plan.get("final_render_intent", {}).get("visual_change_every_seconds", 3),
   149|            "first_frame_hook_required": True,
   150|            "max_words_per_visual_card": 18,
   151|            "max_static_cards": plan.get("final_render_intent", {}).get("static_card_limit", 1),
   152|            "human_approval_required_before_publish": True,
   153|        },
   154|        "production_notes": plan.get("production_notes", []),
   155|    }
   156|
   157|
   158|def _manifest_filename(plan: dict[str, Any]) -> str:
   159|    title_slug = _slug(plan["title"])
   160|    video_id = plan["id"]
   161|    if video_id.endswith(title_slug):
   162|        return f"{video_id}.json"
   163|    return f"{video_id}-{title_slug}.json"
   164|
   165|
   166|def write_render_manifests(plans: list[dict[str, Any]], output_dir: Path) -> list[Path]:
   167|    """Write render manifests as pretty JSON files."""
   168|    output_dir.mkdir(parents=True, exist_ok=True)
   169|    paths: list[Path] = []
   170|    for plan in plans:
   171|        manifest = build_render_manifest(plan)
   172|        path = output_dir / _manifest_filename(plan)
   173|        path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
   174|        paths.append(path)
   175|    return paths
   176|


Explanation:
**Inspecting display issues**

I think there's an issue with the mechanism_pipeline where the on-screen text might be clipped. I’ll need to figure out how to fix it. Maybe using voiceover could be a good alternative? It's important to address this so that everything displays properly. I'll start by inspecting the details and see what adjustments I can make to resolve the problem. Let's get this sorted out!


     1|"""Adapters from Retention Studio mechanism explainers into production shot plans."""
     2|
     3|from __future__ import annotations
     4|
     5|from pathlib import Path
     6|from typing import Any
     7|
     8|from autoshorts.script_drafts import _slug
     9|
    10|BEAT_TO_PURPOSE = {
    11|    "pattern_interrupt": "pattern_interrupt",
    12|    "problem": "problem",
    13|    "mechanism": "mechanism_reveal",
    14|    "twist": "twist",
    15|    "follow_trigger": "follow_trigger",
    16|}
    17|
    18|BEAT_TO_ASSET_TYPE = {
    19|    "pattern_interrupt": "cinematic_ai_video",
    20|    "problem": "cinematic_ai_video",
    21|    "mechanism": "mechanism_animation",
    22|    "twist": "mechanism_animation",
    23|    "follow_trigger": "kinetic_text_overlay",
    24|}
    25|
    26|
    27|def _plan_id(explainer: dict[str, Any]) -> str:
    28|    return f"{_slug(explainer['pillar'])}-{_slug(explainer['title'])}"
    29|
    30|
    31|def _overlay_text(voiceover: str, limit: int = 72) -> str:
    32|    cleaned = " ".join(voiceover.split())
    33|    if len(cleaned) <= limit:
    34|        return cleaned
    35|    return cleaned[:limit].rsplit(" ", 1)[0].rstrip(".,;:")
    36|
    37|
    38|def _scene_from_beat(index: int, beat: dict[str, Any]) -> dict[str, Any]:
    39|    beat_name = beat["beat"]
    40|    return {
    41|        "scene": index,
    42|        "purpose": BEAT_TO_PURPOSE[beat_name],
    43|        "start": beat["start"],
    44|        "end": beat["end"],
    45|        "asset_type": BEAT_TO_ASSET_TYPE[beat_name],
    46|        "on_screen_text": _overlay_text(beat["voiceover"]),
    47|        "voiceover": beat["voiceover"],
    48|        "visual_direction": beat["visual"],
    49|        "visual_motion": beat["visual_motion"],
    50|        "sound_cue": beat["sound_cue"],
    51|        "retention_goal": _retention_goal(beat_name),
    52|    }
    53|
    54|
    55|def _retention_goal(beat_name: str) -> str:
    56|    goals = {
    57|        "pattern_interrupt": "Open a visual loop immediately; make the viewer need the hidden explanation.",
    58|        "problem": "Make the surface-level interpretation feel incomplete.",
    59|        "mechanism": "Reveal the invisible system through moving layers, not narration alone.",
    60|        "twist": "Reframe the obvious cause into the real cause.",
    61|        "follow_trigger": "Promise the next hidden system without a generic like/follow plea.",
    62|    }
    63|    return goals[beat_name]
    64|
    65|
    66|def build_mechanism_shot_plan(explainer: dict[str, Any]) -> dict[str, Any]:
    67|    """Convert a Retention Studio mechanism explainer into a motion-led shot plan."""
    68|    scenes = [_scene_from_beat(index, beat) for index, beat in enumerate(explainer["beats"], start=1)]
    69|    caption_beats = [
    70|        {"start": int(beat["start"]), "text": beat["voiceover"]}
    71|        for beat in explainer["beats"]
    72|    ]
    73|    return {
    74|        "id": _plan_id(explainer),
    75|        "series": explainer["series"],
    76|        "title": explainer["title"],
    77|        "pillar": explainer["pillar"],
    78|        "estimated_seconds": int(explainer["target_seconds"]),
    79|        "caption_beats": caption_beats,
    80|        "scenes": scenes,
    81|        "final_render_intent": {
    82|            "static_card_limit": 0,
    83|            "visual_change_every_seconds": 2,
    84|            "primary_style": "mechanism_reveal_motion_explainer",
    85|            "captions": "large kinetic overlays; voiceover carries detail; no paragraph cards",
    86|        },
    87|    }
    88|
    89|
    90|def format_mechanism_shot_plan(plan: dict[str, Any]) -> str:
    91|    scene_blocks = []
    92|    for scene in plan["scenes"]:
    93|        scene_blocks.append(
    94|            f"### Scene {scene['scene']} — {scene['purpose']}\n\n"
    95|            f"- Timing: {scene['start']}s–{scene['end']}s\n"
    96|            f"- Asset type: {scene['asset_type']}\n"
    97|            f"- On-screen text: {scene['on_screen_text']}\n"
    98|            f"- Voiceover: {scene['voiceover']}\n"
    99|            f"- Visual direction: {scene['visual_direction']}\n"
   100|            f"- Visual motion: {scene['visual_motion']}\n"
   101|            f"- Sound cue: {scene['sound_cue']}\n"
   102|            f"- Retention goal: {scene['retention_goal']}"
   103|        )
   104|    scenes = "\n\n".join(scene_blocks)
   105|    beats = "\n".join(f"- {beat['start']:02d}s: {beat['text']}" for beat in plan["caption_beats"])
   106|    intent = plan["final_render_intent"]
   107|    return f"""# {plan['title']}
   108|
   109|- ID: `{plan['id']}`
   110|- Series: {plan['series']}
   111|- Pillar: `{plan['pillar']}`
   112|- Estimated duration: {plan['estimated_seconds']}s
   113|
   114|## Mechanism shot plan
   115|
   116|{scenes}
   117|
   118|## Caption beats
   119|
   120|{beats}
   121|
   122|## Final render intent
   123|
   124|- Static card limit: {intent['static_card_limit']}
   125|- Visual change every seconds: {intent['visual_change_every_seconds']}
   126|- Primary style: {intent['primary_style']}
   127|- Captions: {intent['captions']}
   128|"""
   129|
   130|
   131|def write_mechanism_shot_plans(explainers: list[dict[str, Any]], output_dir: Path) -> list[Path]:
   132|    """Write mechanism shot plans as Markdown files."""
   133|    output_dir.mkdir(parents=True, exist_ok=True)
   134|    paths: list[Path] = []
   135|    for explainer in explainers:
   136|        plan = build_mechanism_shot_plan(explainer)
   137|        path = output_dir / f"{plan['id']}.md"
   138|        path.write_text(format_mechanism_shot_plan(plan), encoding="utf-8")
   139|        paths.append(path)
   140|    return paths
   141|


Explanation:
**Improving text truncation**

I'm realizing that the text often clips at 72 characters, and it ends awkwardly with "und." To fix this, I think adding an ellipsis could help. We want the renderer overlay to be concise, but it shouldn't dangle at the end. So, I'll test adding a mechanism to ensure it includes an ellipsis when truncated, avoiding stopwords like "und." I also need to make sure "Liquidität" is still included. Maybe I should add a test in the test mechanism pipeline for this.


     1|from autoshorts.mechanism_pipeline import (
     2|    build_mechanism_shot_plan,
     3|    write_mechanism_shot_plans,
     4|)
     5|from autoshorts.retention_studio import PILOT_IDEAS, build_mechanism_explainer
     6|
     7|
     8|def _auto_explainer():
     9|    return build_mechanism_explainer(PILOT_IDEAS[0])
    10|
    11|
    12|def test_build_mechanism_shot_plan_preserves_retention_beats_and_timing():
    13|    explainer = _auto_explainer()
    14|
    15|    plan = build_mechanism_shot_plan(explainer)
    16|
    17|    assert plan["id"] == "hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann"
    18|    assert plan["series"] == "Unsichtbare Systeme in 30 Sekunden"
    19|    assert plan["estimated_seconds"] == 34
    20|    assert [scene["purpose"] for scene in plan["scenes"]] == [
    21|        "pattern_interrupt",
    22|        "problem",
    23|        "mechanism_reveal",
    24|        "twist",
    25|        "follow_trigger",
    26|    ]
    27|    assert plan["scenes"][0]["start"] == 0.0
    28|    assert plan["scenes"][0]["end"] <= 1.5
    29|    assert plan["scenes"][2]["asset_type"] == "mechanism_animation"
    30|    assert all(scene["visual_motion"] for scene in plan["scenes"])
    31|    assert all(scene["sound_cue"] for scene in plan["scenes"])
    32|
    33|
    34|def test_mechanism_shot_plan_uses_motion_led_asset_types_not_static_cards():
    35|    plan = build_mechanism_shot_plan(_auto_explainer())
    36|
    37|    asset_types = [scene["asset_type"] for scene in plan["scenes"]]
    38|
    39|    assert "static_card" not in asset_types
    40|    assert "cinematic_ai_video" in asset_types
    41|    assert "mechanism_animation" in asset_types
    42|    assert "kinetic_text_overlay" in asset_types
    43|    assert plan["final_render_intent"]["static_card_limit"] == 0
    44|    assert plan["final_render_intent"]["visual_change_every_seconds"] <= 2
    45|
    46|
    47|def test_mechanism_shot_plan_generates_caption_beats_from_voiceover():
    48|    plan = build_mechanism_shot_plan(_auto_explainer())
    49|
    50|    assert len(plan["caption_beats"]) == 5
    51|    assert plan["caption_beats"][0]["start"] == 0
    52|    assert "Occasion" in plan["caption_beats"][0]["text"]
    53|    assert plan["caption_beats"][-1]["start"] == 28
    54|    assert "versteckte" in plan["caption_beats"][-1]["text"].lower()
    55|
    56|
    57|def test_write_mechanism_shot_plans_outputs_markdown(tmp_path):
    58|    explainer = _auto_explainer()
    59|
    60|    paths = write_mechanism_shot_plans([explainer], tmp_path)
    61|
    62|    assert len(paths) == 1
    63|    assert paths[0].suffix == ".md"
    64|    text = paths[0].read_text(encoding="utf-8")
    65|    assert "## Mechanism shot plan" in text
    66|    assert "pattern_interrupt" in text
    67|    assert "mechanism_animation" in text
    68|    assert "Sound cue" in text
    69|


patch

def test_mechanism_scene_overlay_truncation_marks_open_sentence():
    plan = build_mechanism_shot_plan(_auto_explainer())
    overlay = plan["scenes"][2]["on_screen_text"]

    assert overlay.endswith("…")
    assert not overlay.endswith(" und…")

def test_write_mechanism_shot_plans_outputs_markdown(tmp_path):


--- a//home/agent/jarvis_runtime/AutoShortsBot/tests/test_mechanism_pipeline.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/tests/test_mechanism_pipeline.py
@@ -54,6 +54,14 @@
     assert "versteckte" in plan["caption_beats"][-1]["text"].lower()

+def test_mechanism_scene_overlay_truncation_marks_open_sentence():
+    plan = build_mechanism_shot_plan(_auto_explainer())
+    overlay = plan["scenes"][2]["on_screen_text"]
+
+    assert overlay.endswith("…")
+    assert not overlay.endswith(" und…")
+
+
 def test_write_mechanism_shot_plans_outputs_markdown(tmp_path):
     explainer = _auto_explainer()


terminal

Output:
F                                                                        [100%]
=================================== FAILURES ===================================
_________ test_mechanism_scene_overlay_truncation_marks_open_sentence __________

    def test_mechanism_scene_overlay_truncation_marks_open_sentence():
        plan = build_mechanism_shot_plan(_auto_explainer())
        overlay = plan["scenes"][2]["on_screen_text"]

>       assert overlay.endswith("…")
E       AssertionError: assert False
E        +  where False = <built-in method endswith of str object at 0x79bb9b6c8390>('…')
E        +    where <built-in method endswith of str object at 0x79bb9b6c8390> = 'Röntgenblick durch Preis, Reparaturrisiko, Standtage, Händler-Marge und'.endswith

tests/test_mechanism_pipeline.py:61: AssertionError
=========================== short test summary info ============================
FAILED tests/test_mechanism_pipeline.py::test_mechanism_scene_overlay_truncation_marks_open_sentence
1 failed in 0.06s


patch

def _overlay_text(voiceover: str, limit: int = 72) -> str:
    cleaned = " ".join(voiceover.split())
    if len(cleaned) <= limit:
        return cleaned
    truncated = cleaned[: limit - 1].rsplit(" ", 1)[0].rstrip(".,;:")
    while truncated.split() and truncated.split()[-1].lower() in {"und", "oder", "mit", "durch", "von", "der", "die", "das"}:
        truncated = " ".join(truncated.split()[:-1]).rstrip(".,;:")
    return f"{truncated}…"

def _overlay_text(voiceover: str, limit: int = 72) -> str:
    cleaned = " ".join(voiceover.split())
    if len(cleaned) <= limit:
        return cleaned
    return cleaned[:limit].rsplit(" ", 1)[0].rstrip(".,;:")


--- a//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/mechanism_pipeline.py
+++ b//home/agent/jarvis_runtime/AutoShortsBot/autoshorts/mechanism_pipeline.py
@@ -32,7 +32,10 @@
     cleaned = " ".join(voiceover.split())
     if len(cleaned) <= limit:
         return cleaned
-    return cleaned[:limit].rsplit(" ", 1)[0].rstrip(".,;:")
+    truncated = cleaned[: limit - 1].rsplit(" ", 1)[0].rstrip(".,;:")
+    while truncated.split() and truncated.split()[-1].lower() in {"und", "oder", "mit", "durch", "von", "der", "die", "das"}:
+        truncated = " ".join(truncated.split()[:-1]).rstrip(".,;:")
+    return f"{truncated}…"

 def _scene_from_beat(index: int, beat: dict[str, Any]) -> dict[str, Any]:


terminal

Output:
.                                                                        [100%]
1 passed in 0.03s


Explanation:
**Assessing artifact regeneration**

I’m considering whether I need to regenerate all downstream artifacts from the shot plan. There’s a mention of scripts generating mechanisms that render from retention. I think I have to run a specific sequence to generate the mechanism shot plans, possibly just in markdown format. Then, I need to render manifests from the script and inspect the arguments involved. It feels like a bit of a process, but it sounds necessary!


     1|"""Generate renderer manifests from Retention Studio mechanism explainers."""
     2|
     3|from __future__ import annotations
     4|
     5|import argparse
     6|import json
     7|import sys
     8|from pathlib import Path
     9|
    10|PROJECT_ROOT = Path(__file__).resolve().parents[1]
    11|if str(PROJECT_ROOT) not in sys.path:
    12|    sys.path.insert(0, str(PROJECT_ROOT))
    13|
    14|from autoshorts.mechanism_pipeline import build_mechanism_shot_plan
    15|from autoshorts.render_manifest import write_render_manifests
    16|
    17|
    18|def main() -> None:
    19|    parser = argparse.ArgumentParser(description="Generate mechanism render manifests.")
    20|    parser.add_argument("--explainers", default="data/retention_studio/mechanism_explainers.json")
    21|    parser.add_argument("--out", default="data/render_manifests/mechanism_pilots")
    22|    parser.add_argument("--title-contains", default=None)
    23|    args = parser.parse_args()
    24|
    25|    explainers = json.loads(Path(args.explainers).read_text(encoding="utf-8"))
    26|    if args.title_contains:
    27|        needle = args.title_contains.lower()
    28|        explainers = [item for item in explainers if needle in item["title"].lower()]
    29|
    30|    plans = [build_mechanism_shot_plan(explainer) for explainer in explainers]
    31|    paths = write_render_manifests(plans, Path(args.out))
    32|    print(f"Generated {len(paths)} mechanism render manifests in {args.out}")
    33|    for path in paths:
    34|        print(path)
    35|
    36|
    37|if __name__ == "__main__":
    38|    main()
    39|


python scripts/generate_mechanism_shot_plans.py --out data/mechanism_shot_plans && python scripts/generate_mechanism_render_manifests.py --out data/render_manifests/mechanism_pilots && python scripts/generate_asset_plans.py --manifest data/render_manifests/mechanism_pilots --out data/asset_plans/mechanism_pilots && python scripts/generate_free_production_jobs.py --asset-plan data/asset_plans/mechanism_pilots --out data/free_production_jobs/mechanism_pilots && python scripts/render_free_local_motion.py --jobs data/free_production_jobs/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.json --out-dir data/local_motion_renders/mechanism_pilots --width 540 --height 960 --fps 15


Explanation:
**Inspecting generated plans**

I accidentally generated 10 plans, which seems excessive since it used to just be one. I need to clarify if multiple files were intended. The summary mentioned only a pilot, but there's also a chance we need retention explainers for all 10. Should I include everything or is that overkill? I'll check the git status and see if the file sizes are manageable. Plus, those German slugs replacing umlauts with hyphens look odd to me. I really should verify the screenshot again at the 8-second mark.

ffmpeg -y -ss 00:00:08 -i data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4 -frames:v 1 /tmp/autoshorts_preview_check/frame_8s_final.jpg >/dev/null 2>&1 && ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate,duration -of default=nw=1:nk=1 data/local_motion_renders/mechanism_pilots/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann.mp4 && git status --short