     1|"""Deterministic renderer-owned subtitle cue planning."""
     2|
     3|from __future__ import annotations
     4|
     5|import re
     6|from dataclasses import dataclass, replace
     7|
     8|from autoshorts.ideas.candidate import ValidationResult
     9|
    10|
    11|_WORD_RE = re.compile(r"[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)?")
    12|
    13|
    14|@dataclass(frozen=True)
    15|class SubtitleStyle:
    16|    name: str
    17|    primary_color: str
    18|    accent_color: str
    19|    stroke_color: str
    20|    shadow_color: str
    21|    placement: str
    22|    max_lines: int
    23|    min_words_per_cue: int
    24|    max_words_per_cue: int
    25|    safe_zone_bottom_percent: int
    26|    font_weight: str
    27|
    28|    @classmethod
    29|    def hormozi_inspired(cls) -> "SubtitleStyle":
    30|        """Return the TrueTrace kinetic-caption baseline: inspired mechanics, original brand skin."""
    31|
    32|        return cls(
    33|            name="truetrace_hormozi_inspired",
    34|            primary_color="#FFFFFF",
    35|            accent_color="#22D3EE",
    36|            stroke_color="#050816",
    37|            shadow_color="#000000",
    38|            placement="lower_middle_above_ui_safe_zone",
    39|            max_lines=2,
    40|            min_words_per_cue=2,
    41|            max_words_per_cue=5,
    42|            safe_zone_bottom_percent=14,
    43|            font_weight="800",
    44|        )
    45|
    46|
    47|@dataclass(frozen=True)
    48|class SubtitleCue:
    49|    text: str
    50|    start_ms: int
    51|    end_ms: int
    52|    active_word: str
    53|    renderer_owned: bool = True
    54|    max_lines: int = 2
    55|    emphasis_color: str = "#22D3EE"
    56|
    57|
    58|@dataclass(frozen=True)
    59|class SubtitleTrack:
    60|    cues: tuple[SubtitleCue, ...]
    61|    style: SubtitleStyle
    62|    renderer_owned: bool = True
    63|    side_effects: tuple[str, ...] = ()
    64|    external_calls: tuple[str, ...] = ()
    65|
    66|    def with_cues(self, cues: tuple[SubtitleCue, ...]) -> "SubtitleTrack":
    67|        return replace(self, cues=cues)
    68|
    69|
    70|def _words(text: str) -> list[str]:
    71|    return _WORD_RE.findall(text)
    72|
    73|
    74|def _chunk_words(words: list[str], *, min_words: int, max_words: int) -> list[list[str]]:
    75|    chunks: list[list[str]] = []
    76|    index = 0
    77|    while index < len(words):
    78|        remaining = len(words) - index
    79|        take = min(max_words, remaining)
    80|        if 0 < remaining < min_words and chunks:
    81|            chunks[-1].extend(words[index:])
    82|            break
    83|        if remaining == min_words + 1:
    84|            take = min_words
    85|        chunks.append(words[index : index + take])
    86|        index += take
    87|    return chunks
    88|
    89|
    90|def _active_word(chunk: list[str]) -> str:
    91|    # Prefer the longest meaningful word so the highlighted token carries semantic weight.
    92|    return max((word.upper() for word in chunk), key=lambda word: (len(word), word))
    93|
    94|
    95|def build_hormozi_subtitle_track(text: str, *, total_duration_seconds: int) -> SubtitleTrack:
    96|    """Create continuous 2..5-word renderer-owned subtitle cues for final burn-in."""
    97|
    98|    if not text.strip():
    99|        raise ValueError("subtitle text is required")
   100|    if total_duration_seconds <= 0:
   101|        raise ValueError("total_duration_seconds must be positive")
   102|
   103|    style = SubtitleStyle.hormozi_inspired()
   104|    words = _words(text)
   105|    if len(words) < style.min_words_per_cue:
   106|        raise ValueError("subtitle text must contain at least two words")
   107|
   108|    chunks = _chunk_words(words, min_words=style.min_words_per_cue, max_words=style.max_words_per_cue)
   109|    total_ms = total_duration_seconds * 1000
   110|    cues: list[SubtitleCue] = []
   111|    for index, chunk in enumerate(chunks):
   112|        start_ms = round(index * total_ms / len(chunks))
   113|        end_ms = round((index + 1) * total_ms / len(chunks))
   114|        cues.append(
   115|            SubtitleCue(
   116|                text=" ".join(word.upper() for word in chunk),
   117|                start_ms=start_ms,
   118|                end_ms=end_ms,
   119|                active_word=_active_word(chunk),
   120|                renderer_owned=True,
   121|                max_lines=style.max_lines,
   122|                emphasis_color=style.accent_color,
   123|            )
   124|        )
   125|
   126|    track = SubtitleTrack(cues=tuple(cues), style=style)
   127|    validation = validate_subtitle_track(track)
   128|    if not validation.is_valid:
   129|        raise ValueError("; ".join(validation.errors))
   130|    return track
   131|
   132|
   133|def validate_subtitle_track(track: SubtitleTrack) -> ValidationResult:
   134|    errors: list[str] = []
   135|    if not track.renderer_owned:
   136|        errors.append("subtitle track must be renderer-owned")
   137|    if track.side_effects:
   138|        errors.append("subtitle track must not have side effects")
   139|    if track.external_calls:
   140|        errors.append("subtitle track must not have external calls")
   141|    if not track.cues:
   142|        errors.append("at least one subtitle cue is required")
   143|
   144|    previous_end: int | None = None
   145|    for index, cue in enumerate(track.cues, start=1):
   146|        cue_words = cue.text.split()
   147|        if not cue.renderer_owned:
   148|            errors.append(f"subtitle cue {index} must be renderer-owned")
   149|        if not (track.style.min_words_per_cue <= len(cue_words) <= track.style.max_words_per_cue):
   150|            errors.append(f"subtitle cue {index} must contain 2..5 words")
   151|        if cue.end_ms <= cue.start_ms:
   152|            errors.append(f"subtitle cue {index} end_ms must be greater than start_ms")
   153|        if previous_end is None:
   154|            if cue.start_ms != 0:
   155|                errors.append("first subtitle cue must start at 0ms")
   156|        elif cue.start_ms != previous_end:
   157|            errors.append(f"subtitle cue {index} must start when the previous cue ends")
   158|        if not cue.active_word.strip():
   159|            errors.append(f"subtitle cue {index} active_word is required")
   160|        elif cue.active_word not in {word.upper() for word in cue_words}:
   161|            errors.append(f"subtitle cue {index} active_word must appear in cue text")
   162|        if cue.max_lines > track.style.max_lines:
   163|            errors.append(f"subtitle cue {index} exceeds style max lines")
   164|        previous_end = cue.end_ms
   165|
   166|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   167|


Explanation:
**Planning scene backgrounds**

I need to generate scene-specific backgrounds, maybe four images, and update the renderer to support those backgrounds for each scene. The subtitle safe zone might need adjusting upward too. So, let's create an asset plan based on that. The user wants me to continue, so I’ll focus next on scene-specific image backgrounds and render them in version 5. I think we won't need TDD for generated images, but will need tests for accepting scene background paths.


     1|"""Audio + subtitle final-preview planning and FFmpeg rendering boundary."""
     2|
     3|from __future__ import annotations
     4|
     5|from collections.abc import Callable
     6|from dataclasses import dataclass
     7|from pathlib import Path
     8|import subprocess
     9|
    10|from autoshorts.audio.subtitles import SubtitleTrack, validate_subtitle_track
    11|from autoshorts.ideas.candidate import ValidationResult
    12|
    13|Runner = Callable[..., subprocess.CompletedProcess[str]]
    14|
    15|
    16|@dataclass(frozen=True)
    17|class AudioCaptionPreviewPlan:
    18|    video_path: str
    19|    audio_path: str
    20|    output_path: str
    21|    subtitle_file_path: str
    22|    subtitle_file_content: str
    23|    subtitle_track: SubtitleTrack
    24|    duration_mode: str = "preserve_video"
    25|    side_effects: tuple[str, ...] = ()
    26|    external_calls: tuple[str, ...] = ()
    27|
    28|
    29|@dataclass(frozen=True)
    30|class AudioCaptionPreviewResult:
    31|    output_path: str
    32|    subtitle_file_path: str
    33|    returncode: int
    34|    stdout: str
    35|    stderr: str
    36|    duration_mode: str = "preserve_video"
    37|    side_effects: tuple[str, ...] = ("write_subtitles", "call_ffmpeg", "write_mp4")
    38|    external_calls: tuple[str, ...] = ("ffmpeg",)
    39|
    40|
    41|def _is_blank(value: str) -> bool:
    42|    return not (value or "").strip()
    43|
    44|
    45|def _ass_timestamp(ms: int) -> str:
    46|    centiseconds = round(ms / 10)
    47|    hours, remainder = divmod(centiseconds, 360000)
    48|    minutes, remainder = divmod(remainder, 6000)
    49|    seconds, centis = divmod(remainder, 100)
    50|    return f"{hours}:{minutes:02d}:{seconds:02d}.{centis:02d}"
    51|
    52|
    53|def _ass_color(hex_color: str) -> str:
    54|    # ASS uses &HAABBGGRR. We keep alpha at 00 and convert #RRGGBB to BGR.
    55|    value = hex_color.removeprefix("#")
    56|    if len(value) != 6:
    57|        return "&H00FFFFFF"
    58|    red, green, blue = value[0:2], value[2:4], value[4:6]
    59|    return f"&H00{blue}{green}{red}"
    60|
    61|
    62|def _escape_ass_text(text: str) -> str:
    63|    return text.replace("{", "").replace("}", "").replace("\n", " ")
    64|
    65|
    66|def render_ass_subtitle_file(track: SubtitleTrack) -> str:
    67|    validation = validate_subtitle_track(track)
    68|    if not validation.is_valid:
    69|        raise ValueError("; ".join(validation.errors))
    70|
    71|    style = track.style
    72|    primary = _ass_color(style.primary_color)
    73|    accent = _ass_color(style.accent_color)
    74|    outline = _ass_color(style.stroke_color)
    75|    lines = [
    76|        "[Script Info]",
    77|        "ScriptType: v4.00+",
    78|        "PlayResX: 1080",
    79|        "PlayResY: 1920",
    80|        "ScaledBorderAndShadow: yes",
    81|        "",
    82|        "[V4+ Styles]",
    83|        "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
    84|        f"Style: TrueTraceCaption,Arial,86,{primary},{accent},{outline},&H80000000,-1,0,0,0,100,100,0,0,1,5,2,2,80,80,260,1",
    85|        "",
    86|        "[Events]",
    87|        "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
    88|    ]
    89|    for cue in track.cues:
    90|        cue_text = _escape_ass_text(cue.text)
    91|        lines.append(
    92|            "Dialogue: 0,"
    93|            f"{_ass_timestamp(cue.start_ms)},{_ass_timestamp(cue.end_ms)},"
    94|            f"TrueTraceCaption,,0,0,0,,{cue_text}"
    95|        )
    96|    return "\n".join(lines) + "\n"
    97|
    98|
    99|def build_audio_caption_preview_plan(
   100|    *,
   101|    video_path: str | Path,
   102|    audio_path: str | Path,
   103|    subtitle_track: SubtitleTrack,
   104|    output_path: str | Path,
   105|    duration_mode: str = "preserve_video",
   106|) -> AudioCaptionPreviewPlan:
   107|    out = Path(output_path)
   108|    plan = AudioCaptionPreviewPlan(
   109|        video_path=str(video_path),
   110|        audio_path=str(audio_path),
   111|        output_path=str(out),
   112|        subtitle_file_path=str(out.with_suffix(".ass")),
   113|        subtitle_file_content=render_ass_subtitle_file(subtitle_track),
   114|        subtitle_track=subtitle_track,
   115|        duration_mode=duration_mode,
   116|        side_effects=(),
   117|        external_calls=(),
   118|    )
   119|    validation = validate_audio_caption_preview_plan(plan)
   120|    if not validation.is_valid:
   121|        raise ValueError("; ".join(validation.errors))
   122|    return plan
   123|
   124|
   125|def validate_audio_caption_preview_plan(plan: AudioCaptionPreviewPlan) -> ValidationResult:
   126|    errors: list[str] = []
   127|    for field_name, value in {
   128|        "video_path": plan.video_path,
   129|        "audio_path": plan.audio_path,
   130|        "output_path": plan.output_path,
   131|        "subtitle_file_path": plan.subtitle_file_path,
   132|        "subtitle_file_content": plan.subtitle_file_content,
   133|    }.items():
   134|        if _is_blank(value):
   135|            errors.append(f"{field_name} is required")
   136|    if plan.video_path and not plan.video_path.endswith(".mp4"):
   137|        errors.append("video_path must end with .mp4")
   138|    if plan.audio_path and not plan.audio_path.endswith(".mp3"):
   139|        errors.append("audio_path must end with .mp3")
   140|    if plan.output_path and not plan.output_path.endswith(".mp4"):
   141|        errors.append("output_path must end with .mp4")
   142|    if plan.subtitle_file_path and not plan.subtitle_file_path.endswith(".ass"):
   143|        errors.append("subtitle_file_path must end with .ass")
   144|    if plan.duration_mode not in {"preserve_video", "trim_to_audio"}:
   145|        errors.append("duration_mode must be preserve_video or trim_to_audio")
   146|    if plan.side_effects:
   147|        errors.append("audio caption preview plans must not have side effects")
   148|    if plan.external_calls:
   149|        errors.append("audio caption preview plans must not have external calls")
   150|    errors.extend(validate_subtitle_track(plan.subtitle_track).errors)
   151|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   152|
   153|
   154|def _ass_filter_path(path: str) -> str:
   155|    # Avoid shell quoting entirely by passing argv; escape colon/backslash for FFmpeg filter parser.
   156|    return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")
   157|
   158|
   159|def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
   160|    command = [
   161|        "ffmpeg",
   162|        "-y",
   163|        "-i",
   164|        plan.video_path,
   165|        "-i",
   166|        plan.audio_path,
   167|        "-vf",
   168|        f"ass='{_ass_filter_path(plan.subtitle_file_path)}'",
   169|        "-map",
   170|        "0:v:0",
   171|        "-map",
   172|        "1:a:0",
   173|        "-c:v",
   174|        "libx264",
   175|        "-c:a",
   176|        "aac",
   177|    ]
   178|    if plan.duration_mode == "trim_to_audio":
   179|        command.append("-shortest")
   180|    command.extend([
   181|        "-movflags",
   182|        "+faststart",
   183|        plan.output_path,
   184|    ])
   185|    return command
   186|
   187|
   188|def render_audio_caption_preview(plan: AudioCaptionPreviewPlan, *, runner: Runner = subprocess.run) -> AudioCaptionPreviewResult:
   189|    """Write ASS subtitles and call FFmpeg to mux voiceover + burn captions into an MP4."""
   190|
   191|    validation = validate_audio_caption_preview_plan(plan)
   192|    if not validation.is_valid:
   193|        raise ValueError("; ".join(validation.errors))
   194|    if not Path(plan.video_path).exists():
   195|        raise FileNotFoundError(plan.video_path)
   196|    if not Path(plan.audio_path).exists():
   197|        raise FileNotFoundError(plan.audio_path)
   198|
   199|    subtitle_path = Path(plan.subtitle_file_path)
   200|    output_path = Path(plan.output_path)
   201|    subtitle_path.parent.mkdir(parents=True, exist_ok=True)
   202|    output_path.parent.mkdir(parents=True, exist_ok=True)
   203|    subtitle_path.write_text(plan.subtitle_file_content)
   204|
   205|    completed = runner(_command(plan), capture_output=True, text=True, check=False)
   206|    if completed.returncode != 0:
   207|        raise RuntimeError(completed.stderr or "ffmpeg audio/caption preview failed")
   208|    if not output_path.exists():
   209|        raise RuntimeError("ffmpeg completed but did not create output video")
   210|
   211|    return AudioCaptionPreviewResult(
   212|        output_path=str(output_path),
   213|        subtitle_file_path=str(subtitle_path),
   214|        returncode=completed.returncode,
   215|        stdout=completed.stdout,
   216|        stderr=completed.stderr,
   217|        duration_mode=plan.duration_mode,
   218|        side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),
   219|        external_calls=("ffmpeg",),
   220|    )
   221|


Explanation:
**Considering style adjustments**

I’m thinking about whether to add a style for the safe zone that affects the margin for subtitles. The existing safe zone has a margin of 260, which I could increase or adjust. Maybe I should consider a function to compact things better for a safer style? I wonder if the user mentioned to continue after the final motion. I’ll also check on rendering backgrounds for each scene and see if I can generate images concurrently. First, though, I need to code the tests.

from pathlib import Path

from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.scene_backgrounds import (
    SceneBackgroundPlan,
    build_chatgpt_image_scene_background_plan,
    validate_scene_background_plan,
)

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

    plan = build_chatgpt_image_scene_background_plan(final_plan, output_dir=tmp_path)

    assert isinstance(plan, SceneBackgroundPlan)
    assert plan.candidate_id == final_plan.candidate_id
    assert [asset.scene_id for asset in plan.assets] == [scene.scene_id for scene in final_plan.scenes]
    assert all(asset.provider == "chatgpt_image_codex" for asset in plan.assets)
    assert all(asset.output_path.endswith(".png") for asset in plan.assets)
    assert all("no readable text" in asset.prompt.casefold() for asset in plan.assets)
    assert all("no logos" in asset.prompt.casefold() for asset in plan.assets)
    assert all(asset.renderer_owned_overlay_required is True for asset in plan.assets)
    assert plan.side_effects == ()
    assert plan.external_calls == ()

def test_validate_scene_background_plan_rejects_text_or_side_effects(tmp_path):
    voiceover = build_truetrace_final_voiceover("truetrace-ai-automation-quiet-rot-38s")
    final_plan = build_truetrace_weird_email_final_video_plan(voiceover)
    valid = build_chatgpt_image_scene_background_plan(final_plan, output_dir=tmp_path)
    broken_asset = valid.assets[0].__class__(
        scene_id="clean-demo",
        provider="chatgpt_image_codex",
        prompt="Generate a dashboard with readable text and logos",
        output_path=str(tmp_path / "bad.jpg"),
        renderer_owned_overlay_required=False,
    )
    broken = valid.__class__(
        candidate_id=valid.candidate_id,
        assets=(broken_asset,),
        side_effects=("call_image_api",),
        external_calls=("openai",),
    )

    validation = validate_scene_background_plan(broken)

    assert validation.is_valid is False
    assert "scene background plan must not have side effects" in validation.errors
    assert "scene background plan must not have external calls" in validation.errors
    assert "asset 1 output_path must end with .png" in validation.errors
    assert "asset 1 prompt must explicitly forbid readable text" in validation.errors
    assert "asset 1 prompt must explicitly forbid logos" in validation.errors
    assert "asset 1 requires renderer-owned overlays" in validation.errors


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


patch

    background_paths = {scene.scene_id: tmp_path / f"{scene.scene_id}.png" for scene in final_plan.scenes}
    for path in background_paths.values():
        path.write_bytes(b"fake image")
    render_plan = build_motion_render_plan(
        final_plan,
        output_dir=tmp_path,
        preview_scale=0.5,
        background_paths=background_paths,
    )

    assert render_plan.background_paths == {scene_id: str(path) for scene_id, path in background_paths.items()}
    assert render_plan.width == 540

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

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


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

-    background = tmp_path / "chatgpt-image-background.png"
-    background.write_bytes(b"fake image")
-    render_plan = build_motion_render_plan(final_plan, output_dir=tmp_path, preview_scale=0.5, background_path=background)
+    background_paths = {scene.scene_id: tmp_path / f"{scene.scene_id}.png" for scene in final_plan.scenes}
+    for path in background_paths.values():
+        path.write_bytes(b"fake image")
+    render_plan = build_motion_render_plan(
+        final_plan,
+        output_dir=tmp_path,
+        preview_scale=0.5,
+        background_paths=background_paths,
+    )

-    assert render_plan.background_path == str(background)
+    assert render_plan.background_paths == {scene_id: str(path) for scene_id, path in background_paths.items()}
     assert render_plan.width == 540
     assert render_plan.height == 960
     assert render_plan.fps == 24

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


terminal

Output:
==================================== ERRORS ====================================
_______________ ERROR collecting tests/test_scene_backgrounds.py _______________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_scene_backgrounds.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_scene_backgrounds.py:5: in <module>
    from autoshorts.rendering.scene_backgrounds import (
E   ModuleNotFoundError: No module named 'autoshorts.rendering.scene_backgrounds'
=========================== short test summary info ============================
ERROR tests/test_scene_backgrounds.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.16s


"""Scene-specific text-free background planning for final-near motion renders."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

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

@dataclass(frozen=True)
class SceneBackgroundAsset:
    scene_id: str
    provider: str
    prompt: str
    output_path: str
    renderer_owned_overlay_required: bool = True

@dataclass(frozen=True)
class SceneBackgroundPlan:
    candidate_id: str
    assets: tuple[SceneBackgroundAsset, ...]
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

_SCENE_PROMPT_NOTES = {
    "clean-demo": "clean modern laptop workspace, calm inbox dashboard glow, orderly automation demo mood",
    "weird-email": "slightly messy email-workflow atmosphere, amber warning mood, desk close-up, shallow depth of field",
    "wrong-routing": "abstract AI routing node ambience, glowing path energy, subtle red warning tension",
    "three-way-split": "minimal dark technical background with empty space for a three-branch diagram overlay",
    "final-rule": "premium minimal blue-green abstract technology background, calm final-rule energy",
}

def build_chatgpt_image_scene_background_plan(final_plan: FinalVideoPlan, *, output_dir: str | Path) -> SceneBackgroundPlan:
    validation = validate_final_video_plan(final_plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    root = Path(output_dir)
    assets = tuple(
        SceneBackgroundAsset(
            scene_id=scene.scene_id,
            provider="chatgpt_image_codex",
            prompt=(
                "Vertical 9:16 cinematic background for a TrueTrace AI automation short. "
                f"Scene: {scene.scene_id}. {_SCENE_PROMPT_NOTES.get(scene.scene_id, scene.asset_prompt)}. "
                "No readable text, no logos, no watermarks, no UI labels, no fake letters. "
                "Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. "
                "Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered."
            ),
            output_path=str(root / f"{scene.scene_id}.png"),
            renderer_owned_overlay_required=True,
        )
        for scene in final_plan.scenes
    )
    plan = SceneBackgroundPlan(candidate_id=final_plan.candidate_id, assets=assets)
    validation = validate_scene_background_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    return plan

def validate_scene_background_plan(plan: SceneBackgroundPlan) -> ValidationResult:
    errors: list[str] = []
    if not plan.candidate_id.strip():
        errors.append("candidate_id is required")
    if not plan.assets:
        errors.append("at least one scene background asset is required")
    if plan.side_effects:
        errors.append("scene background plan must not have side effects")
    if plan.external_calls:
        errors.append("scene background plan must not have external calls")
    seen: set[str] = set()
    for index, asset in enumerate(plan.assets, start=1):
        if not asset.scene_id.strip():
            errors.append(f"asset {index} scene_id is required")
        if asset.scene_id in seen:
            errors.append(f"duplicate scene background asset: {asset.scene_id}")
        seen.add(asset.scene_id)
        if asset.provider != "chatgpt_image_codex":
            errors.append(f"asset {index} provider must be chatgpt_image_codex")
        if not asset.output_path.endswith(".png"):
            errors.append(f"asset {index} output_path must end with .png")
        prompt = asset.prompt.casefold()
        if "no readable text" not in prompt:
            errors.append(f"asset {index} prompt must explicitly forbid readable text")
        if "no logos" not in prompt:
            errors.append(f"asset {index} prompt must explicitly forbid logos")
        if not asset.renderer_owned_overlay_required:
            errors.append(f"asset {index} requires renderer-owned overlays")
    return ValidationResult(is_valid=not errors, errors=tuple(errors))


patch

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

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


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

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py">
ERROR [71:9] No parameter named "background_path" [reportCallIssue] (Pyright)
ERROR [248:13] Cannot access attribute "background_path" for class "MotionRenderPlan"
  Attribute "background_path" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [249:38] Cannot access attribute "background_path" for class "MotionRenderPlan"
  Attribute "background_path" is unknown [reportAttributeAccessIssue] (Pyright)
</diagnostics>


    preview_scale: float = 1.0,
    background_paths: dict[str, str | Path] | None = None,
) -> MotionRenderPlan:

    preview_scale: float = 1.0,
    background_path: str | Path | None = None,
) -> MotionRenderPlan:


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -48,7 +48,7 @@
     *,
     output_dir: str | Path,
     preview_scale: float = 1.0,
-    background_path: str | Path | None = None,
+    background_paths: dict[str, str | Path] | None = None,
 ) -> MotionRenderPlan:
     validation = validate_final_video_plan(final_plan)
     if not validation.is_valid:

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py">
ERROR [71:49] "background_path" is not defined [reportUndefinedVariable] (Pyright)
ERROR [71:29] "background_path" is not defined [reportUndefinedVariable] (Pyright)
</diagnostics>


        fps=fps,
        background_paths={scene_id: str(path) for scene_id, path in (background_paths or {}).items()} or None,
    )

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


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py
@@ -68,7 +68,7 @@
         width=width,
         height=height,
         fps=fps,
-        background_path=str(background_path) if background_path is not None else None,
+        background_paths={scene_id: str(path) for scene_id, path in (background_paths or {}).items()} or None,
     )
     validation = validate_motion_render_plan(plan)
     if not validation.is_valid:


    background_cache: dict[str, Image.Image] = {}
    for scene_id, path in (plan.background_paths or {}).items():
        background_cache[scene_id] = Image.open(path).convert("RGB").resize((plan.width, plan.height), Image.Resampling.LANCZOS)
    frame_index = 0

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


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

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/rendering/motion_renderer.py">
ERROR [258:69] "background" is not defined [reportUndefinedVariable] (Pyright)
</diagnostics>


image = _render_scene(plan, scene, progress, background=background_cache.get(scene.scene_id))

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


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


terminal

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


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


Explanation:
**Generating scene images**

I'm thinking I need to generate 5 scene images. I could use the image generation tool, maybe in a sequential manner or through parallel execution. Although, I'm wondering if 5 is too many to handle at once. The parallel wrapper seems flexible enough to accommodate this, so I should explore how to best combine the image generation with the multi_tool capabilities. It feels like I have a few options here!

python - <<'PY'
from pathlib import Path
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.scene_backgrounds import build_chatgpt_image_scene_background_plan
voiceover=build_truetrace_final_voiceover('truetrace-ai-automation-quiet-rot-38s')
plan=build_truetrace_weird_email_final_video_plan(voiceover)
bg=build_chatgpt_image_scene_background_plan(plan, output_dir='data/generated_backgrounds/truetrace-weird-email-v1')
for asset in bg.assets:
    print('---', asset.scene_id)
    print(asset.output_path)
    print(asset.prompt)
PY


Output:
--- clean-demo
data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png
Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: clean-demo. clean modern laptop workspace, calm inbox dashboard glow, orderly automation demo mood. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.
--- weird-email
data/generated_backgrounds/truetrace-weird-email-v1/weird-email.png
Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: weird-email. slightly messy email-workflow atmosphere, amber warning mood, desk close-up, shallow depth of field. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.
--- wrong-routing
data/generated_backgrounds/truetrace-weird-email-v1/wrong-routing.png
Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: wrong-routing. abstract AI routing node ambience, glowing path energy, subtle red warning tension. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.
--- three-way-split
data/generated_backgrounds/truetrace-weird-email-v1/three-way-split.png
Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: three-way-split. minimal dark technical background with empty space for a three-branch diagram overlay. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.
--- final-rule
data/generated_backgrounds/truetrace-weird-email-v1/final-rule.png
Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: final-rule. premium minimal blue-green abstract technology background, calm final-rule energy. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: clean-demo. Clean modern laptop workspace, calm inbox dashboard glow, orderly automation demo mood. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.

Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: weird-email. Slightly messy email-workflow atmosphere, amber warning mood, desk close-up, shallow depth of field. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.

Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: wrong-routing. Abstract AI routing node ambience, glowing path energy, subtle red warning tension. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: clean-demo. Clean modern laptop workspace, calm inbox dashboard glow, orderly automation demo mood. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: weird-email. Slightly messy email-workflow atmosphere, amber warning mood, desk close-up, shallow depth of field. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: wrong-routing. Abstract AI routing node ambience, glowing path energy, subtle red warning tension. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: three-way-split. Minimal dark technical background with empty space for a three-branch diagram overlay. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.

Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: final-rule. Premium minimal blue-green abstract technology background, calm final-rule energy. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: three-way-split. Minimal dark technical background with empty space for a three-branch diagram overlay. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Prompt: Vertical 9:16 cinematic background for a TrueTrace AI automation short. Scene: final-rule. Premium minimal blue-green abstract technology background, calm final-rule energy. No readable text, no logos, no watermarks, no UI labels, no fake letters. Leave clean central negative space for renderer-owned cards, labels, arrows, and captions. Modern premium tech aesthetic, dark blue/green palette, realistic but not cluttered.


Need copy to data/generated... use terminal cp? Use terminal for cp ok.

mkdir -p data/generated_backgrounds/truetrace-weird-email-v1 && cp /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002134_d3c821b5.png data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png && cp /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002219_f22bec3f.png data/generated_backgrounds/truetrace-weird-email-v1/weird-email.png && cp /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002251_1dfe8a3e.png data/generated_backgrounds/truetrace-weird-email-v1/wrong-routing.png && cp /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002346_15f5da53.png data/generated_backgrounds/truetrace-weird-email-v1/three-way-split.png && cp /home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002425_90559d8e.png data/generated_backgrounds/truetrace-weird-email-v1/final-rule.png && sha256sum data/generated_backgrounds/truetrace-weird-email-v1/*.png


Output:
bf5cb0ea87177d298be147ce00c998ee4d3b04f1c102b1934b286cc2b14117e5  data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png
27c2135bfa512c06f57e9676b36915199ded08d112864912ee390a7a8f1a56c9  data/generated_backgrounds/truetrace-weird-email-v1/final-rule.png
8b0c27c2039e0d3fffca7c3bec015df3fae54f167c32933563ec039a6860e3aa  data/generated_backgrounds/truetrace-weird-email-v1/three-way-split.png
cd11aca663e55c6ee6e24aec3d60feb0cfc2973ed73f39eb7b3bbc66870b6fb5  data/generated_backgrounds/truetrace-weird-email-v1/weird-email.png
d6c8864c95295bd284a0b26ae7c32880b7ee689276c7588c56c05b8e288e462d  data/generated_backgrounds/truetrace-weird-email-v1/wrong-routing.png


python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.probe import probe_media_duration_seconds
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview
from autoshorts.rendering.final_video_plan import build_truetrace_weird_email_final_video_plan
from autoshorts.rendering.scene_backgrounds import build_chatgpt_image_scene_background_plan
from autoshorts.rendering.motion_renderer import build_motion_render_plan, render_motion_preview
candidate_id='truetrace-ai-automation-quiet-rot-38s'
output_dir=Path('data/previews/truetrace-final-motion-v5')/candidate_id
voiceover=build_truetrace_final_voiceover(candidate_id)
final_plan=build_truetrace_weird_email_final_video_plan(voiceover)
bg_plan=build_chatgpt_image_scene_background_plan(final_plan, output_dir='data/generated_backgrounds/truetrace-weird-email-v1')
background_paths={asset.scene_id: asset.output_path for asset in bg_plan.assets}
motion=render_motion_preview(build_motion_render_plan(final_plan, output_dir=output_dir, preview_scale=0.5, background_paths=background_paths))
if motion.returncode: raise SystemExit(motion.stderr)
voice=synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=build_default_truetrace_tts_plan(), output_path=str(output_dir/'voice.mp3')))
audio_duration=probe_media_duration_seconds(voice.output_path)
subtitles=build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
final=render_audio_caption_preview(build_audio_caption_preview_plan(video_path=motion.output_path,audio_path=voice.output_path,subtitle_track=subtitles,output_path=output_dir/'final-motion-audio-caption-preview.mp4',duration_mode='trim_to_audio'))
if final.returncode: raise SystemExit(final.stderr)
print('final', final.output_path)
print('frames', len(motion.frame_paths))
print('backgrounds', len(background_paths))
print('audio_duration', f'{audio_duration:.3f}')
PY
rm -rf /tmp/truetrace_review_v5 && mkdir -p /tmp/truetrace_review_v5 && ffmpeg -y -i data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 -vf "select='eq(n,0)+eq(n,120)+eq(n,300)+eq(n,520)+eq(n,760)'" -vsync 0 /tmp/truetrace_review_v5/frame_%02d.png >/dev/null 2>&1
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
sha256sum data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


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


Question: Review this v5 three-way split frame after scene-specific ChatGPT Image background. Check mobile readability, text clipping, subtitle overlap, and whether the mechanism is clearer than a static card.


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

Question: Review this v5 three-way split frame after scene-specific ChatGPT Image background. Check mobile readability, text clipping, subtitle overlap, and whether the mechanism is clearer than a static card.


Explanation:
**Evaluating image output**

I’m checking the vision output, and the actual image looks good. However, I’m noticing that the subtitle might be overlapping a bit at the bottom. Well, that’s not ideal, but it’s manageable. I think I need to evaluate the final frame more to see if there’s anything else I should adjust. Overall, it seems like I’m on the right track, but small details matter!

Question: Review the weird-email scene frame for readability, no clipped renderer text, and whether AI background avoids readable fake text/logos.


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

Question: Review the weird-email scene frame for readability, no clipped renderer text, and whether AI background avoids readable fake text/logos.


git status --short && git check-ignore -v data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4 || true


Output:
M autoshorts/rendering/motion_renderer.py
 M tests/test_motion_renderer.py
?? autoshorts/rendering/scene_backgrounds.py
?? tests/test_scene_backgrounds.py
.gitignore:27:*.png	data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png
.gitignore:20:data/previews/	data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4


patch

- `autoshorts.rendering.scene_backgrounds` — scene-specific ChatGPT Image/Codex background prompt plans that explicitly forbid readable text/logos and require renderer-owned overlays.
- `autoshorts.rendering.motion_renderer` — procedural motion preview renderer for inbox cards, warning routes, and three-path mechanism diagrams before heavier AI-video work.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -36,6 +36,7 @@
 - `autoshorts.strategy.score` — follower-conversion strategy scoring for hook, curiosity, identity, emotion, trust, follow reason, originality, and variety.
 - `autoshorts.strategy.series` — public-safe series registry and hard no-go validation before public content advances.
 - `autoshorts.rendering.final_video_plan` — final-near 1080x1920 production plans that keep AI visuals text-free and renderer-owned.
+- `autoshorts.rendering.scene_backgrounds` — scene-specific ChatGPT Image/Codex background prompt plans that explicitly forbid readable text/logos and require renderer-owned overlays.
 - `autoshorts.rendering.motion_renderer` — procedural motion preview renderer for inbox cards, warning routes, and three-path mechanism diagrams before heavier AI-video work.
 - `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
 - `autoshorts.rendering.ffmpeg_renderer` — explicit FFmpeg boundary that writes concat files and MP4 timing previews.

No linter for .md files


- Next polish should improve subtitle phrasing/timing around the three-way split and optionally replace the single static background with per-scene generated backgrounds or short I2V texture clips.

## V5 update

`autoshorts.rendering.scene_backgrounds.build_chatgpt_image_scene_background_plan()` now creates one text-free ChatGPT Image/Codex prompt per production scene. The motion renderer accepts a `background_paths` mapping so each scene can use its own background while keeping all text, arrows, cards, labels, and captions deterministic.

Current v5 review artifact:

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

V5 uses five generated backgrounds under:

```text
data/generated_backgrounds/truetrace-weird-email-v1/
```

These media files remain ignored by Git.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/final_motion_prototype.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/final_motion_prototype.md
@@ -65,3 +65,21 @@
 - The three-way split scene has been visually checked so arrows sit behind labels and subtitles do not critically overlap the mechanism.
 - The prototype is still a review prototype, not a final public export.
 - Next polish should improve subtitle phrasing/timing around the three-way split and optionally replace the single static background with per-scene generated backgrounds or short I2V texture clips.
+
+## V5 update
+
+`autoshorts.rendering.scene_backgrounds.build_chatgpt_image_scene_background_plan()` now creates one text-free ChatGPT Image/Codex prompt per production scene. The motion renderer accepts a `background_paths` mapping so each scene can use its own background while keeping all text, arrows, cards, labels, and captions deterministic.
+
+Current v5 review artifact:
+
+```text
+data/previews/truetrace-final-motion-v5/truetrace-ai-automation-quiet-rot-38s/final-motion-audio-caption-preview.mp4
+```
+
+V5 uses five generated backgrounds under:
+
+```text
+data/generated_backgrounds/truetrace-weird-email-v1/
+```
+
+These media files remain ignored by Git.

No linter for .md files


terminal

Output:
........................................................................ [ 42%]
........................................................................ [ 85%]
........................                                                 [100%]
168 passed in 33.05s


git status --short && git add autoshorts/rendering/scene_backgrounds.py autoshorts/rendering/motion_renderer.py tests/test_scene_backgrounds.py tests/test_motion_renderer.py README.md docs/final_motion_prototype.md && git commit -m "feat: add scene-specific motion backgrounds" && git status --short && git log --oneline -5


Output:
M README.md
 M autoshorts/rendering/motion_renderer.py
 M docs/final_motion_prototype.md
 M tests/test_motion_renderer.py
?? autoshorts/rendering/scene_backgrounds.py
?? tests/test_scene_backgrounds.py
[feat/core-approval-quality-gates db200ae] feat: add scene-specific motion backgrounds
 6 files changed, 187 insertions(+), 12 deletions(-)
 create mode 100644 autoshorts/rendering/scene_backgrounds.py
 create mode 100644 tests/test_scene_backgrounds.py
db200ae feat: add scene-specific motion backgrounds
14310f9 feat: add TrueTrace final motion prototype
7375311 feat: make TrueTrace pilot beginner friendly
b9b6423 feat: align TrueTrace voiceover previews
3f68001 feat: add EdgeTTS audio caption preview


Erledigt, Sir.

## Weitergebaut: Scene-specific ChatGPT Image Backgrounds

Ich habe den Motion-Prototypen von **einem generischen Hintergrund** auf **5 szenenspezifische ChatGPT Image/Codex Backgrounds** erweitert.

Neuer Commit:

```text
db200ae feat: add scene-specific motion backgrounds
```

## Neu gebaut

### Neues Modul

```text
autoshorts/rendering/scene_backgrounds.py
```

### Neue Tests

```text
tests/test_scene_backgrounds.py
```

### Erweitert

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

Der Renderer kann jetzt pro Szene ein eigenes Hintergrundbild verwenden:

```python
background_paths = {
    "clean-demo": ".../clean-demo.png",
    "weird-email": ".../weird-email.png",
    "wrong-routing": ".../wrong-routing.png",
    "three-way-split": ".../three-way-split.png",
    "final-rule": ".../final-rule.png",
}
```

## Was generiert wurde

Ich habe über ChatGPT Image/Codex 5 textfreie Backgrounds erzeugt:

```text
data/generated_backgrounds/truetrace-weird-email-v1/clean-demo.png
data/generated_backgrounds/truetrace-weird-email-v1/weird-email.png
data/generated_backgrounds/truetrace-weird-email-v1/wrong-routing.png
data/generated_backgrounds/truetrace-weird-email-v1/three-way-split.png
data/generated_backgrounds/truetrace-weird-email-v1/final-rule.png
```

Alle Prompts erzwingen:

```text
no readable text
no logos
no watermarks
no UI labels
renderer-owned overlays
```

Also: AI macht Atmosphäre, Renderer macht Wahrheit. So wie es sein sollte.

## Neuer V5 Review Clip

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

## Technische Verifikation

```text
Video: h264
Size: 540x960
Duration: 36.833s
Audio: aac
Audio duration: 36.864s
Frames: 887
Backgrounds: 5
```

SHA256:

```text
ac8432ef5285abaa8bdb662610184cea491d3b1951dc9b2f2b6dd5a9f4ea9143
```

## Visuelle QA

Ich habe zwei kritische Szenen geprüft:

### Weird Email Scene

Status:

```text
Readable: yes
Renderer text clear: yes
No critical clipping: yes
AI background has no problematic readable fake text/logos
```

### Three-way Split Scene

Status:

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

Ist lesbar, die Pfeile liegen hinter den Labels, und das wirkt jetzt deutlich mehr wie ein Mechanism Reveal statt nur Karten-Slideshow.

## Tests

Full Suite:

```text
168 passed in 33.05s
```

## Aktueller Verlauf

```text
db200ae feat: add scene-specific motion backgrounds
14310f9 feat: add TrueTrace final motion prototype
7375311 feat: make TrueTrace pilot beginner friendly
b9b6423 feat: align TrueTrace voiceover previews
3f68001 feat: add EdgeTTS audio caption preview
```

## Nächster sinnvoller Schritt

Jetzt würde ich als nächstes **Caption/Subtitle Timing und Layout polieren**, weil der Clip visuell langsam brauchbar wird.

Konkret:

```text
1. Subtitle chunks bei der 3-way-split Szene kürzer machen
2. Subtitles etwas höher/kompakter setzen
3. Key scene text nicht mit subtitles konkurrieren lassen
4. Danach 1080x1920 Export erzeugen
```

Dann hätten wir einen ernsthaften First-Public-Candidate statt nur einen technischen Prototypen mit Hut.