     1|"""Side-effect-free FFmpeg concat planning for local preview frames."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|from pathlib import Path
     7|
     8|from autoshorts.ideas.candidate import ValidationResult
     9|from autoshorts.rendering.local_frame_writer import LocalFrameWriteResult
    10|from autoshorts.rendering.local_preview import LocalPreviewPlan, validate_local_preview_plan
    11|
    12|
    13|@dataclass(frozen=True)
    14|class FFmpegConcatEntry:
    15|    image_path: str
    16|    duration_seconds: float
    17|
    18|
    19|@dataclass(frozen=True)
    20|class FFmpegConcatPlan:
    21|    script_id: str
    22|    output_path: str
    23|    concat_file_path: str
    24|    width: int
    25|    height: int
    26|    fps: int
    27|    entries: tuple[FFmpegConcatEntry, ...]
    28|    concat_file_content: str
    29|    side_effects: tuple[str, ...] = ()
    30|    external_calls: tuple[str, ...] = ()
    31|
    32|
    33|def _is_blank(value: str) -> bool:
    34|    return not (value or "").strip()
    35|
    36|
    37|def _quote_concat_path(path: str) -> str:
    38|    return path.replace("'", "'\\''")
    39|
    40|
    41|def _concat_content(entries: tuple[FFmpegConcatEntry, ...]) -> str:
    42|    lines: list[str] = []
    43|    for entry in entries:
    44|        lines.append(f"file '{_quote_concat_path(entry.image_path)}'")
    45|        lines.append(f"duration {entry.duration_seconds:.3f}")
    46|    if entries:
    47|        lines.append(f"file '{_quote_concat_path(entries[-1].image_path)}'")
    48|    return "\n".join(lines) + "\n"
    49|
    50|
    51|def build_ffmpeg_concat_plan(
    52|    preview_plan: LocalPreviewPlan,
    53|    write_result: LocalFrameWriteResult,
    54|    *,
    55|    output_path: str | Path,
    56|) -> FFmpegConcatPlan:
    57|    """Build an FFmpeg concat plan without writing files or calling FFmpeg."""
    58|
    59|    preview_validation = validate_local_preview_plan(preview_plan)
    60|    if not preview_validation.is_valid:
    61|        raise ValueError("; ".join(preview_validation.errors))
    62|    if write_result.script_id != preview_plan.script_id:
    63|        raise ValueError("write result script_id must match preview plan")
    64|    if len(write_result.frame_paths) != len(preview_plan.frames):
    65|        raise ValueError("frame count must match preview plan")
    66|    if any(not path.endswith(".png") for path in write_result.frame_paths):
    67|        raise ValueError("frame paths must be png files")
    68|    if write_result.external_calls:
    69|        raise ValueError("frame write result must not include external calls")
    70|
    71|    entries = tuple(
    72|        FFmpegConcatEntry(
    73|            image_path=image_path,
    74|            duration_seconds=frame.duration_ms / 1000,
    75|        )
    76|        for frame, image_path in zip(preview_plan.frames, write_result.frame_paths, strict=True)
    77|    )
    78|    out_path = Path(output_path)
    79|    concat_path = out_path.with_suffix(".concat.txt")
    80|    plan = FFmpegConcatPlan(
    81|        script_id=preview_plan.script_id,
    82|        output_path=str(out_path),
    83|        concat_file_path=str(concat_path),
    84|        width=preview_plan.width,
    85|        height=preview_plan.height,
    86|        fps=preview_plan.fps,
    87|        entries=entries,
    88|        concat_file_content=_concat_content(entries),
    89|        side_effects=(),
    90|        external_calls=(),
    91|    )
    92|    validation = validate_ffmpeg_concat_plan(plan)
    93|    if not validation.is_valid:
    94|        raise ValueError("; ".join(validation.errors))
    95|    return plan
    96|
    97|
    98|def validate_ffmpeg_concat_plan(plan: FFmpegConcatPlan) -> ValidationResult:
    99|    errors: list[str] = []
   100|    if _is_blank(plan.script_id):
   101|        errors.append("script_id is required")
   102|    if _is_blank(plan.output_path):
   103|        errors.append("output_path is required")
   104|    elif not plan.output_path.endswith(".mp4"):
   105|        errors.append("output_path must end with .mp4")
   106|    if _is_blank(plan.concat_file_path):
   107|        errors.append("concat_file_path is required")
   108|    elif not plan.concat_file_path.endswith(".concat.txt"):
   109|        errors.append("concat_file_path must end with .concat.txt")
   110|    if plan.width <= 0 or plan.height <= 0:
   111|        errors.append("width and height must be positive")
   112|    if plan.fps <= 0:
   113|        errors.append("fps must be positive")
   114|    if plan.side_effects:
   115|        errors.append("ffmpeg concat plans must not have side effects")
   116|    if plan.external_calls:
   117|        errors.append("ffmpeg concat plans must not call external tools")
   118|    if not plan.entries:
   119|        errors.append("at least one concat entry is required")
   120|    if _is_blank(plan.concat_file_content):
   121|        errors.append("concat_file_content is required")
   122|
   123|    for index, entry in enumerate(plan.entries, start=1):
   124|        if _is_blank(entry.image_path):
   125|            errors.append(f"entry {index} image_path is required")
   126|        elif not entry.image_path.endswith(".png"):
   127|            errors.append("frame paths must be png files")
   128|        if entry.duration_seconds <= 0:
   129|            errors.append(f"entry {index} duration_seconds must be positive")
   130|
   131|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   132|


     1|[project]
     2|name = "autoshortsbot"
     3|version = "0.1.0"
     4|description = "Semi-automatic short-form video growth engine with human approval gates."
     5|requires-python = ">=3.11"
     6|dependencies = [
     7|    "Pillow>=10.0",
     8|]
     9|
    10|[tool.pytest.ini_options]
    11|pythonpath = ["."]
    12|testpaths = ["tests"]
    13|


terminal

python - <<'PY'
import importlib.util, shutil
print('edge_tts_module', bool(importlib.util.find_spec('edge_tts')))
print('edge-tts_cli', shutil.which('edge-tts'))
print('ffmpeg', shutil.which('ffmpeg'))
PY


Output:
edge_tts_module True
edge-tts_cli /home/agent/.local/bin/edge-tts
ffmpeg /usr/bin/ffmpeg


Explanation:
**Considering testing approaches**

I'm thinking about whether I should run some actual ffmpeg tests. I might want to utilize dataclasses in this process. It's important for me to ensure that everything runs smoothly in the tests I write. I'll take my time to write thorough tests later on. This will help me prepare for real scenarios when I use ffmpeg in a practical application. Let's get organized and make sure to cover all the bases!

from pathlib import Path
import subprocess

from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts, validate_edge_tts_request
from autoshorts.audio.tts_plan import build_default_truetrace_tts_plan

def test_synthesize_edge_tts_builds_command_and_reports_explicit_side_effects(tmp_path):
    output_path = tmp_path / "voice.mp3"
    calls = []

    def fake_runner(command, *, capture_output, text, check):
        calls.append(command)
        output_path.write_bytes(b"fake mp3")
        return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")

    result = synthesize_edge_tts(
        EdgeTTSRequest(
            text="Most AI automations quietly rot.",
            tts_plan=build_default_truetrace_tts_plan(),
            output_path=str(output_path),
        ),
        runner=fake_runner,
    )

    assert output_path.exists()
    assert calls == [[
        "edge-tts",
        "--voice",
        "en-GB-RyanNeural",
        "--text",
        "Most AI automations quietly rot.",
        "--write-media",
        str(output_path),
    ]]
    assert result.output_path == str(output_path)
    assert result.voice == "en-GB-RyanNeural"
    assert result.side_effects == ("call_edge_tts", "write_audio")
    assert result.external_calls == ("edge-tts",)

def test_validate_edge_tts_request_rejects_bad_provider_blank_text_and_non_mp3(tmp_path):
    plan = build_default_truetrace_tts_plan()
    bad_provider = EdgeTTSRequest(
        text="valid text",
        tts_plan=plan.__class__(
            provider="nvidia_magpie_tts_multilingual",
            voice="default",
            language="en",
            role="voice_generation",
            requires_local_gpu=False,
            blocks_video_gpu=False,
            requires_api_key=True,
        ),
        output_path=str(tmp_path / "voice.mp3"),
    )
    blank_text = EdgeTTSRequest(text="", tts_plan=plan, output_path=str(tmp_path / "voice.mp3"))
    bad_suffix = EdgeTTSRequest(text="valid text", tts_plan=plan, output_path=str(tmp_path / "voice.wav"))

    assert "EdgeTTS adapter requires provider edge_tts" in validate_edge_tts_request(bad_provider).errors
    assert "text is required" in validate_edge_tts_request(blank_text).errors
    assert "output_path must end with .mp3" in validate_edge_tts_request(bad_suffix).errors


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


from pathlib import Path
import subprocess

from autoshorts.audio.final_preview import (
    AudioCaptionPreviewPlan,
    build_audio_caption_preview_plan,
    render_audio_caption_preview,
    validate_audio_caption_preview_plan,
)
from autoshorts.audio.subtitles import build_hormozi_subtitle_track

def test_build_audio_caption_preview_plan_is_side_effect_free_and_writes_ass_content(tmp_path):
    video_path = tmp_path / "timing.mp4"
    audio_path = tmp_path / "voice.mp3"
    track = build_hormozi_subtitle_track("Most AI automations quietly rot when workflows lack boundaries", total_duration_seconds=5)

    plan = build_audio_caption_preview_plan(
        video_path=video_path,
        audio_path=audio_path,
        subtitle_track=track,
        output_path=tmp_path / "final-preview.mp4",
    )

    assert plan.video_path == str(video_path)
    assert plan.audio_path == str(audio_path)
    assert plan.output_path.endswith("final-preview.mp4")
    assert plan.subtitle_file_path.endswith("final-preview.ass")
    assert "[Script Info]" in plan.subtitle_file_content
    assert "Dialogue:" in plan.subtitle_file_content
    assert "MOST AI AUTOMATIONS" in plan.subtitle_file_content
    assert plan.side_effects == ()
    assert plan.external_calls == ()

def test_validate_audio_caption_preview_plan_rejects_bad_paths_and_unsafe_side_effects(tmp_path):
    track = build_hormozi_subtitle_track("valid subtitle text", total_duration_seconds=2)
    plan = AudioCaptionPreviewPlan(
        video_path="video.mov",
        audio_path="voice.wav",
        output_path="out.avi",
        subtitle_file_path="out.srt",
        subtitle_file_content="",
        subtitle_track=track,
        side_effects=("write_file",),
        external_calls=("ffmpeg",),
    )

    validation = validate_audio_caption_preview_plan(plan)

    assert validation.is_valid is False
    assert "video_path must end with .mp4" in validation.errors
    assert "audio_path must end with .mp3" in validation.errors
    assert "output_path must end with .mp4" in validation.errors
    assert "subtitle_file_path must end with .ass" in validation.errors
    assert "audio caption preview plans must not have side effects" in validation.errors

def test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg(tmp_path):
    input_video = tmp_path / "timing.mp4"
    audio = tmp_path / "voice.mp3"
    output = tmp_path / "final.mp4"
    input_video.write_bytes(b"fake video")
    audio.write_bytes(b"fake audio")
    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)
    plan = build_audio_caption_preview_plan(
        video_path=input_video,
        audio_path=audio,
        subtitle_track=track,
        output_path=output,
    )
    calls = []

    def fake_runner(command, *, capture_output, text, check):
        calls.append(command)
        output.write_bytes(b"fake final video")
        return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")

    result = render_audio_caption_preview(plan, runner=fake_runner)

    assert Path(plan.subtitle_file_path).exists()
    assert output.exists()
    assert calls[0][:5] == ["ffmpeg", "-y", "-i", str(input_video), "-i"]
    assert "ass=" in " ".join(calls[0])
    assert result.output_path == str(output)
    assert result.side_effects == ("write_subtitles", "call_ffmpeg", "write_mp4")
    assert result.external_calls == ("ffmpeg",)


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


pytest tests/test_edge_tts_adapter.py tests/test_audio_caption_preview.py -q


Output:
==================================== ERRORS ====================================
_______________ ERROR collecting tests/test_edge_tts_adapter.py ________________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_edge_tts_adapter.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_edge_tts_adapter.py:4: in <module>
    from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts, validate_edge_tts_request
E   ModuleNotFoundError: No module named 'autoshorts.audio.edge_tts_adapter'
_____________ ERROR collecting tests/test_audio_caption_preview.py _____________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.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_audio_caption_preview.py:4: in <module>
    from autoshorts.audio.final_preview import (
E   ModuleNotFoundError: No module named 'autoshorts.audio.final_preview'
=========================== short test summary info ============================
ERROR tests/test_edge_tts_adapter.py
ERROR tests/test_audio_caption_preview.py
!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!
2 errors in 0.13s


Explanation:
**Evaluating implementation details**

I need to confirm the correct import for ValidationResult and consider using a subprocess runner. There are edge checks to ensure output existence; in my fake setup, the real check should be different. I need to build the final preview and handle content escaping properly. It's basic, but I’m questioning whether the FFmpeg command for the ASS path filter needs escaping. Rendering should validate that the input file exists, and I’ll need to test bad paths for side effects. Let's implement this!

"""Explicit EdgeTTS adapter boundary for voiceover generation."""

from __future__ import annotations

from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
import subprocess

from autoshorts.audio.tts_plan import TTSProviderPlan, validate_tts_provider_plan
from autoshorts.ideas.candidate import ValidationResult

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

@dataclass(frozen=True)
class EdgeTTSRequest:
    text: str
    tts_plan: TTSProviderPlan
    output_path: str
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

@dataclass(frozen=True)
class EdgeTTSResult:
    output_path: str
    provider: str
    voice: str
    returncode: int
    stdout: str
    stderr: str
    side_effects: tuple[str, ...] = ("call_edge_tts", "write_audio")
    external_calls: tuple[str, ...] = ("edge-tts",)

def _is_blank(value: str) -> bool:
    return not (value or "").strip()

def validate_edge_tts_request(request: EdgeTTSRequest) -> ValidationResult:
    errors: list[str] = []
    if _is_blank(request.text):
        errors.append("text is required")
    if _is_blank(request.output_path):
        errors.append("output_path is required")
    elif not request.output_path.endswith(".mp3"):
        errors.append("output_path must end with .mp3")
    if request.side_effects:
        errors.append("EdgeTTS requests must not have side effects")
    if request.external_calls:
        errors.append("EdgeTTS requests must not have external calls")

    plan_validation = validate_tts_provider_plan(request.tts_plan)
    errors.extend(plan_validation.errors)
    if request.tts_plan.provider != "edge_tts":
        errors.append("EdgeTTS adapter requires provider edge_tts")
    if request.tts_plan.role != "voice_generation":
        errors.append("EdgeTTS adapter requires a voice_generation plan")
    if request.tts_plan.blocks_video_gpu:
        errors.append("EdgeTTS adapter must not block the local video GPU")
    return ValidationResult(is_valid=not errors, errors=tuple(errors))

def _command(request: EdgeTTSRequest) -> list[str]:
    return [
        "edge-tts",
        "--voice",
        request.tts_plan.voice,
        "--text",
        request.text,
        "--write-media",
        request.output_path,
    ]

def synthesize_edge_tts(request: EdgeTTSRequest, *, runner: Runner = subprocess.run) -> EdgeTTSResult:
    """Call EdgeTTS to write an MP3 voiceover. This is an explicit side-effect boundary."""

    validation = validate_edge_tts_request(request)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))

    output_path = Path(request.output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    completed = runner(_command(request), capture_output=True, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr or "edge-tts failed")
    if not output_path.exists():
        raise RuntimeError("edge-tts completed but did not create output audio")

    return EdgeTTSResult(
        output_path=str(output_path),
        provider=request.tts_plan.provider,
        voice=request.tts_plan.voice,
        returncode=completed.returncode,
        stdout=completed.stdout,
        stderr=completed.stderr,
        side_effects=("call_edge_tts", "write_audio"),
        external_calls=("edge-tts",),
    )


"""Audio + subtitle final-preview planning and FFmpeg rendering boundary."""

from __future__ import annotations

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

from autoshorts.audio.subtitles import SubtitleTrack, validate_subtitle_track
from autoshorts.ideas.candidate import ValidationResult

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

@dataclass(frozen=True)
class AudioCaptionPreviewPlan:
    video_path: str
    audio_path: str
    output_path: str
    subtitle_file_path: str
    subtitle_file_content: str
    subtitle_track: SubtitleTrack
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

@dataclass(frozen=True)
class AudioCaptionPreviewResult:
    output_path: str
    subtitle_file_path: str
    returncode: int
    stdout: str
    stderr: str
    side_effects: tuple[str, ...] = ("write_subtitles", "call_ffmpeg", "write_mp4")
    external_calls: tuple[str, ...] = ("ffmpeg",)

def _is_blank(value: str) -> bool:
    return not (value or "").strip()

def _ass_timestamp(ms: int) -> str:
    centiseconds = round(ms / 10)
    hours, remainder = divmod(centiseconds, 360000)
    minutes, remainder = divmod(remainder, 6000)
    seconds, centis = divmod(remainder, 100)
    return f"{hours}:{minutes:02d}:{seconds:02d}.{centis:02d}"

def _ass_color(hex_color: str) -> str:
    # ASS uses &HAABBGGRR. We keep alpha at 00 and convert #RRGGBB to BGR.
    value = hex_color.removeprefix("#")
    if len(value) != 6:
        return "&H00FFFFFF"
    red, green, blue = value[0:2], value[2:4], value[4:6]
    return f"&H00{blue}{green}{red}"

def _escape_ass_text(text: str) -> str:
    return text.replace("{", "").replace("}", "").replace("\n", " ")

def render_ass_subtitle_file(track: SubtitleTrack) -> str:
    validation = validate_subtitle_track(track)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))

    style = track.style
    primary = _ass_color(style.primary_color)
    accent = _ass_color(style.accent_color)
    outline = _ass_color(style.stroke_color)
    lines = [
        "[Script Info]",
        "ScriptType: v4.00+",
        "PlayResX: 1080",
        "PlayResY: 1920",
        "ScaledBorderAndShadow: yes",
        "",
        "[V4+ Styles]",
        "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
        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",
        "",
        "[Events]",
        "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
    ]
    for cue in track.cues:
        cue_text = _escape_ass_text(cue.text)
        active = _escape_ass_text(cue.active_word)
        # Put the active word on its own highlighted override when possible, while preserving readable plain text.
        if active and active in cue_text:
            cue_text = cue_text.replace(active, f"{{\\c{accent}}}{active}{{\\c{primary}}}", 1)
        lines.append(
            "Dialogue: 0,"
            f"{_ass_timestamp(cue.start_ms)},{_ass_timestamp(cue.end_ms)},"
            f"TrueTraceCaption,,0,0,0,,{cue_text}"
        )
    return "\n".join(lines) + "\n"

def build_audio_caption_preview_plan(
    *,
    video_path: str | Path,
    audio_path: str | Path,
    subtitle_track: SubtitleTrack,
    output_path: str | Path,
) -> AudioCaptionPreviewPlan:
    out = Path(output_path)
    plan = AudioCaptionPreviewPlan(
        video_path=str(video_path),
        audio_path=str(audio_path),
        output_path=str(out),
        subtitle_file_path=str(out.with_suffix(".ass")),
        subtitle_file_content=render_ass_subtitle_file(subtitle_track),
        subtitle_track=subtitle_track,
        side_effects=(),
        external_calls=(),
    )
    validation = validate_audio_caption_preview_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    return plan

def validate_audio_caption_preview_plan(plan: AudioCaptionPreviewPlan) -> ValidationResult:
    errors: list[str] = []
    for field_name, value in {
        "video_path": plan.video_path,
        "audio_path": plan.audio_path,
        "output_path": plan.output_path,
        "subtitle_file_path": plan.subtitle_file_path,
        "subtitle_file_content": plan.subtitle_file_content,
    }.items():
        if _is_blank(value):
            errors.append(f"{field_name} is required")
    if plan.video_path and not plan.video_path.endswith(".mp4"):
        errors.append("video_path must end with .mp4")
    if plan.audio_path and not plan.audio_path.endswith(".mp3"):
        errors.append("audio_path must end with .mp3")
    if plan.output_path and not plan.output_path.endswith(".mp4"):
        errors.append("output_path must end with .mp4")
    if plan.subtitle_file_path and not plan.subtitle_file_path.endswith(".ass"):
        errors.append("subtitle_file_path must end with .ass")
    if plan.side_effects:
        errors.append("audio caption preview plans must not have side effects")
    if plan.external_calls:
        errors.append("audio caption preview plans must not have external calls")
    errors.extend(validate_subtitle_track(plan.subtitle_track).errors)
    return ValidationResult(is_valid=not errors, errors=tuple(errors))

def _ass_filter_path(path: str) -> str:
    # Avoid shell quoting entirely by passing argv; escape colon/backslash for FFmpeg filter parser.
    return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")

def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
    return [
        "ffmpeg",
        "-y",
        "-i",
        plan.video_path,
        "-i",
        plan.audio_path,
        "-vf",
        f"ass='{_ass_filter_path(plan.subtitle_file_path)}'",
        "-map",
        "0:v:0",
        "-map",
        "1:a:0",
        "-c:v",
        "libx264",
        "-c:a",
        "aac",
        "-shortest",
        "-movflags",
        "+faststart",
        plan.output_path,
    ]

def render_audio_caption_preview(plan: AudioCaptionPreviewPlan, *, runner: Runner = subprocess.run) -> AudioCaptionPreviewResult:
    """Write ASS subtitles and call FFmpeg to mux voiceover + burn captions into an MP4."""

    validation = validate_audio_caption_preview_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    if not Path(plan.video_path).exists():
        raise FileNotFoundError(plan.video_path)
    if not Path(plan.audio_path).exists():
        raise FileNotFoundError(plan.audio_path)

    subtitle_path = Path(plan.subtitle_file_path)
    output_path = Path(plan.output_path)
    subtitle_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    subtitle_path.write_text(plan.subtitle_file_content)

    completed = runner(_command(plan), capture_output=True, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr or "ffmpeg audio/caption preview failed")
    if not output_path.exists():
        raise RuntimeError("ffmpeg completed but did not create output video")

    return AudioCaptionPreviewResult(
        output_path=str(output_path),
        subtitle_file_path=str(subtitle_path),
        returncode=completed.returncode,
        stdout=completed.stdout,
        stderr=completed.stderr,
        side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),
        external_calls=("ffmpeg",),
    )


{"output": "..F..                                                                    [100%]\n=================================== FAILURES ===================================\n_ test_build_audio_caption_preview_plan_is_side_effect_free_and_writes_ass_content _\n\ntmp_path = PosixPath('/tmp/pytest-of-agent/pytest-116/test_build_audio_caption_previ0')\n\n    def test_build_audio_caption_preview_plan_is_side_effect_free_and_writes_ass_content(tmp_path):\n        video_path = tmp_path / \"timing.mp4\"\n        audio_path = tmp_path / \"voice.mp3\"\n        track = build_hormozi_subtitle_track(\"Most AI automations quietly rot when workflows lack boundaries\", total_duration_seconds=5)\n    \n        plan = build_audio_caption_preview_plan(\n            video_path=video_path,\n            audio_path=audio_path,\n            subtitle_track=track,\n            output_path=tmp_path / \"final-preview.mp4\",\n        )\n    \n        assert plan.video_path == str(video_path)\n        assert plan.audio_path == str(audio_path)\n        assert plan.output_path.endswith(\"final-preview.mp4\")\n        assert plan.subtitle_file_path.endswith(\"final-preview.ass\")\n        assert \"[Script Info]\" in plan.subtitle_file_content\n        assert \"Dialogue:\" in plan.subtitle_file_content\n>       assert \"MOST AI AUTOMATIONS\" in plan.subtitle_file_content\nE       AssertionError: assert 'MOST AI AUTOMATIONS' in '[Script Info]\\nScriptType: v4.00+\\nPlayResX: 1080\\nPlayResY: 1920\\nScaledBorderAndShadow: yes\\n\\n[V4+ Styles]\\nFormat...logue: 0,0:00:02.50,0:00:05.00,TrueTraceCaption,,0,0,0,,WHEN WORKFLOWS LACK {\\\\c&H00EED322}BOUNDARIES{\\\\c&H00FFFFFF}\\n'\nE        +  where '[Script Info]\\nScriptType: v4.00+\\nPlayResX: 1080\\nPlayResY: 1920\\nScaledBorderAndShadow: yes\\n\\n[V4+ Styles]\\nFormat...logue: 0,0:00:02.50,0:00:05.00,TrueTraceCaption,,0,0,0,,WHEN WORKFLOWS LACK {\\\\c&H00EED322}BOUNDARIES{\\\\c&H00FFFFFF}\\n' = AudioCaptionPreviewPlan(video_path='/tmp/pytest-of-agent/pytest-116/test_build_audio_caption_previ0/timing.mp4', audio...t=14, font_weight='800'), renderer_owned=True, side_effects=(), external_calls=()), side_effects=(), external_calls=()).subtitle_file_content\n\ntests/test_audio_caption_preview.py:31: AssertionError\n=========================== short test summary info ============================\nFAILED tests/test_audio_caption_preview.py::test_build_audio_caption_preview_plan_is_side_effect_free_and_writes_ass_content\n1 failed, 4 passed in 0.07s", "exit_code": 1, "error": null}

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


patch

        cue_text = _escape_ass_text(cue.text)
        active = _escape_ass_text(cue.active_word)
        # Put the active word on its own highlighted override when possible, while preserving readable plain text.
        if active and active in cue_text:
            cue_text = cue_text.replace(active, f"{{\\c{accent}}}{active}{{\\c{primary}}}", 1)
        lines.append(

        cue_text = _escape_ass_text(cue.text)
        lines.append(


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -86,10 +86,6 @@
     ]
     for cue in track.cues:
         cue_text = _escape_ass_text(cue.text)
-        active = _escape_ass_text(cue.active_word)
-        # Put the active word on its own highlighted override when possible, while preserving readable plain text.
-        if active and active in cue_text:
-            cue_text = cue_text.replace(active, f"{{\\c{accent}}}{active}{{\\c{primary}}}", 1)
         lines.append(
             "Dialogue: 0,"
             f"{_ass_timestamp(cue.start_ms)},{_ass_timestamp(cue.end_ms)},"


terminal

Output:
.....                                                                    [100%]
5 passed in 0.06s


Explanation:
**Considering audio integration**

I’m thinking about integrating the top preview. Should I add a final preview package or just focus on generating the first clip? Maybe I should also create a demo module to generate audio and caption previews for the initial candidate. I’ll plan a function to build the audio caption preview using TDD. It seems I should run the edge text-to-speech manually along with ffmpeg for the first artifact. I'll need to resolve any potential issues with quoting in ffmpeg.
**Finalizing rendering and synthesis**

I’ll render the three components again, but I think I can aim for 20 seconds this time. After that, I’ll synthesize the first part and then mux it all together. It seems straightforward, but I want to ensure all elements are working smoothly after the render. I'll keep an eye on the timing and quality to make sure everything aligns properly. This process feels a bit repetitive, but I'm committed to getting it right!

python - <<'PY'
from pathlib import Path
from autoshorts.demo.truetrace_preview_packages import build_top_truetrace_preview_packages
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview

out_dir = Path('data/previews/truetrace-audio-caption-first').resolve()
result = build_top_truetrace_preview_packages(output_dir=out_dir, approval_language='de')
artifact = result.artifacts[0]
voice_text = ' '.join((artifact.script.hook, *artifact.script.retention_beats, artifact.script.cta))
voice_path = out_dir / artifact.candidate.candidate_id / 'voice.mp3'
final_path = out_dir / artifact.candidate.candidate_id / 'audio-caption-preview.mp4'
print('candidate', artifact.candidate.candidate_id)
print('preview', artifact.package.preview_video_path)
print('voice_text_chars', len(voice_text))
voice = synthesize_edge_tts(EdgeTTSRequest(text=voice_text, tts_plan=artifact.tts_plan, output_path=str(voice_path)))
print('voice', voice.output_path)
plan = build_audio_caption_preview_plan(
    video_path=artifact.package.preview_video_path,
    audio_path=voice.output_path,
    subtitle_track=artifact.subtitle_track,
    output_path=final_path,
)
rendered = render_audio_caption_preview(plan)
print('final', rendered.output_path)
PY


Explanation:
**Verifying media properties**

I need to check the dimensions, duration, and audio of the media using ffprobe. It's essential to ensure everything is accurate since any discrepancies could cause issues later on. Getting the right information will help in processing the media correctly. I’ll run the necessary commands to extract those properties and confirm the details. Let’s make sure everything lines up as expected! Alright, time to get started on that verification!

ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.mp4 && sha256sum data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.mp4 data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/voice.mp3


Output:
codec_name=h264
codec_type=video
width=540
height=960
duration=28.833333
codec_name=aac
codec_type=audio
duration=28.848000
12d1a4b5d37e5d479727248f022b32b2bfc91b0bde706e3f3f69f93e178ed3a0  data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.mp4
3377b93b878e50fed7f231c5e5c84b1bbae0cf72e5f46ac3859b38e0e3dfd735  data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/voice.mp3


patch

    assert "ass=" in " ".join(calls[0])
    assert "-shortest" not in calls[0]
    assert result.output_path == str(output)

    assert "ass=" in " ".join(calls[0])
    assert result.output_path == str(output)


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.py
@@ -82,6 +82,7 @@
     assert output.exists()
     assert calls[0][:5] == ["ffmpeg", "-y", "-i", str(input_video), "-i"]
     assert "ass=" in " ".join(calls[0])
+    assert "-shortest" not in calls[0]
     assert result.output_path == str(output)
     assert result.side_effects == ("write_subtitles", "call_ffmpeg", "write_mp4")
     assert result.external_calls == ("ffmpeg",)


terminal

Output:
F                                                                        [100%]
=================================== FAILURES ===================================
_______ test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg ________

tmp_path = PosixPath('/tmp/pytest-of-agent/pytest-118/test_render_audio_caption_prev0')

    def test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg(tmp_path):
        input_video = tmp_path / "timing.mp4"
        audio = tmp_path / "voice.mp3"
        output = tmp_path / "final.mp4"
        input_video.write_bytes(b"fake video")
        audio.write_bytes(b"fake audio")
        track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)
        plan = build_audio_caption_preview_plan(
            video_path=input_video,
            audio_path=audio,
            subtitle_track=track,
            output_path=output,
        )
        calls = []

        def fake_runner(command, *, capture_output, text, check):
            calls.append(command)
            output.write_bytes(b"fake final video")
            return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")

        result = render_audio_caption_preview(plan, runner=fake_runner)

        assert Path(plan.subtitle_file_path).exists()
        assert output.exists()
        assert calls[0][:5] == ["ffmpeg", "-y", "-i", str(input_video), "-i"]
        assert "ass=" in " ".join(calls[0])
>       assert "-shortest" not in calls[0]
E       AssertionError: assert '-shortest' not in ['ffmpeg', '-y', '-i', '/tmp/pytest-of-agent/pytest-118/test_render_audio_caption_prev0/timing.mp4', '-i', '/tmp/pytest-of-agent/pytest-118/test_render_audio_caption_prev0/voice.mp3', ...]

tests/test_audio_caption_preview.py:85: AssertionError
=========================== short test summary info ============================
FAILED tests/test_audio_caption_preview.py::test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg
1 failed in 0.09s


patch

        "-c:a",
        "aac",
        "-movflags",

        "-c:a",
        "aac",
        "-shortest",
        "-movflags",


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -168,7 +168,6 @@
         "libx264",
         "-c:a",
         "aac",
-        "-shortest",
         "-movflags",
         "+faststart",
         plan.output_path,


terminal

Output:
.....                                                                    [100%]
5 passed in 0.06s


python - <<'PY'
from pathlib import Path
from autoshorts.demo.truetrace_preview_packages import build_top_truetrace_preview_packages
from autoshorts.audio.edge_tts_adapter import EdgeTTSRequest, synthesize_edge_tts
from autoshorts.audio.final_preview import build_audio_caption_preview_plan, render_audio_caption_preview

out_dir = Path('data/previews/truetrace-audio-caption-first').resolve()
result = build_top_truetrace_preview_packages(output_dir=out_dir, approval_language='de')
artifact = result.artifacts[0]
voice_text = ' '.join((artifact.script.hook, *artifact.script.retention_beats, artifact.script.cta))
voice_path = out_dir / artifact.candidate.candidate_id / 'voice.mp3'
final_path = out_dir / artifact.candidate.candidate_id / 'audio-caption-preview.mp4'
if not voice_path.exists():
    synthesize_edge_tts(EdgeTTSRequest(text=voice_text, tts_plan=artifact.tts_plan, output_path=str(voice_path)))
plan = build_audio_caption_preview_plan(
    video_path=artifact.package.preview_video_path,
    audio_path=str(voice_path),
    subtitle_track=artifact.subtitle_track,
    output_path=final_path,
)
rendered = render_audio_caption_preview(plan)
print(rendered.output_path)
PY
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.mp4


Output:
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=38.041667
codec_name=aac
codec_type=audio
duration=28.848000


Explanation:
**Evaluating project requirements**

I need to update documentation, run full tests, and make a commit. There's a possibility I should add the "edge-tts" dependency, as it's used in the project but not imported. It might be correct to specify a requirement like "edge-tts>=7.0." Checking the current version in the terminal isn't necessary but still a good idea. I wonder if I should skip TDD for this, especially since the package may already be installed. I'll make sure the README and documentation are up to date as well.

patch

dependencies = [
    "Pillow>=10.0",
    "edge-tts>=7.0",
]


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/pyproject.toml
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/pyproject.toml
@@ -5,6 +5,7 @@
 requires-python = ">=3.11"
 dependencies = [
     "Pillow>=10.0",
+    "edge-tts>=7.0",
 ]

 [tool.pytest.ini_options]


- `autoshorts.audio.subtitles` — deterministic Hormozi-inspired, renderer-owned subtitle cue planning with active-word metadata.
- `autoshorts.audio.tts_plan` — side-effect-free TTS provider planning: EdgeTTS phase-1 baseline, Magpie A/B candidate, Whisper alignment-only.
- `autoshorts.audio.edge_tts_adapter` — explicit EdgeTTS MP3 generation boundary for final voiceover previews.
- `autoshorts.audio.final_preview` — ASS subtitle generation plus FFmpeg audio/caption MP4 rendering boundary.
- `autoshorts.ideas.candidate` — structured video candidate model and validation.

- `autoshorts.audio.subtitles` — deterministic Hormozi-inspired, renderer-owned subtitle cue planning with active-word metadata.
- `autoshorts.audio.tts_plan` — side-effect-free TTS provider planning: EdgeTTS phase-1 baseline, Magpie A/B candidate, Whisper alignment-only.
- `autoshorts.ideas.candidate` — structured video candidate model and validation.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -23,6 +23,8 @@
 - `autoshorts.demo.truetrace_preview_packages` — renders top-3 TrueTrace local MP4 timing previews plus approval/manual upload packages.
 - `autoshorts.audio.subtitles` — deterministic Hormozi-inspired, renderer-owned subtitle cue planning with active-word metadata.
 - `autoshorts.audio.tts_plan` — side-effect-free TTS provider planning: EdgeTTS phase-1 baseline, Magpie A/B candidate, Whisper alignment-only.
+- `autoshorts.audio.edge_tts_adapter` — explicit EdgeTTS MP3 generation boundary for final voiceover previews.
+- `autoshorts.audio.final_preview` — ASS subtitle generation plus FFmpeg audio/caption MP4 rendering boundary.
 - `autoshorts.ideas.candidate` — structured video candidate model and validation.
 - `autoshorts.ideas.factory` — draft-to-candidate factory with stable ids, default hashtags, and duration classes.
 - `autoshorts.ideas.testbatch` — testbatch validation and renderable-candidate filtering.

No linter for .md files


- `autoshorts.demo.truetrace_preview_packages.build_top_truetrace_preview_packages()` attaches the subtitle track and EdgeTTS baseline plan to each top-3 preview artifact before any real audio generation.
- `autoshorts.audio.edge_tts_adapter.synthesize_edge_tts()` is the explicit EdgeTTS side-effect boundary. It calls `edge-tts`, writes an MP3, and reports `side_effects=("call_edge_tts", "write_audio")`.
- `autoshorts.audio.final_preview.build_audio_caption_preview_plan()` creates an ASS subtitle file plan from the cue track without writing files.
- `autoshorts.audio.final_preview.render_audio_caption_preview()` writes the ASS subtitle file and calls FFmpeg to mux EdgeTTS audio plus burn captions into a review MP4.

## Caption pipeline

- `autoshorts.demo.truetrace_preview_packages.build_top_truetrace_preview_packages()` attaches the subtitle track and EdgeTTS baseline plan to each top-3 preview artifact before any real audio generation.

## Caption pipeline


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/subtitle_tts_strategy.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/subtitle_tts_strategy.md
@@ -57,6 +57,9 @@
 - `autoshorts.audio.tts_plan.build_default_truetrace_tts_plan()` selects `edge_tts` with `en-GB-RyanNeural` for phase 1.
 - `autoshorts.audio.tts_plan.build_tts_ab_test_plan()` keeps NVIDIA Magpie as a hosted A/B candidate and Whisper as `alignment_qa`, not voice generation.
 - `autoshorts.demo.truetrace_preview_packages.build_top_truetrace_preview_packages()` attaches the subtitle track and EdgeTTS baseline plan to each top-3 preview artifact before any real audio generation.
+- `autoshorts.audio.edge_tts_adapter.synthesize_edge_tts()` is the explicit EdgeTTS side-effect boundary. It calls `edge-tts`, writes an MP3, and reports `side_effects=("call_edge_tts", "write_audio")`.
+- `autoshorts.audio.final_preview.build_audio_caption_preview_plan()` creates an ASS subtitle file plan from the cue track without writing files.
+- `autoshorts.audio.final_preview.render_audio_caption_preview()` writes the ASS subtitle file and calls FFmpeg to mux EdgeTTS audio plus burn captions into a review MP4.

 ## Caption pipeline

No linter for .md files


terminal

Output:
........................................................................ [ 46%]
........................................................................ [ 93%]
..........                                                               [100%]
154 passed in 31.65s


git status --short && git diff --stat && git add autoshorts/audio/edge_tts_adapter.py autoshorts/audio/final_preview.py tests/test_edge_tts_adapter.py tests/test_audio_caption_preview.py README.md docs/subtitle_tts_strategy.md pyproject.toml && git commit -m "feat: add EdgeTTS audio caption preview" && git status --short && git log --oneline -5


Output:
M README.md
 M docs/subtitle_tts_strategy.md
 M pyproject.toml
?? autoshorts/audio/edge_tts_adapter.py
?? autoshorts/audio/final_preview.py
?? tests/test_audio_caption_preview.py
?? tests/test_edge_tts_adapter.py
 README.md                     | 2 ++
 docs/subtitle_tts_strategy.md | 3 +++
 pyproject.toml                | 1 +
 3 files changed, 6 insertions(+)
[feat/core-approval-quality-gates 3f68001] feat: add EdgeTTS audio caption preview
 7 files changed, 465 insertions(+)
 create mode 100644 autoshorts/audio/edge_tts_adapter.py
 create mode 100644 autoshorts/audio/final_preview.py
 create mode 100644 tests/test_audio_caption_preview.py
 create mode 100644 tests/test_edge_tts_adapter.py
3f68001 feat: add EdgeTTS audio caption preview
066201d feat: attach audio plans to TrueTrace previews
30a8129 feat: add subtitle and tts planning
6efc4dc feat: add TrueTrace top preview packages
2d6af9f feat: add TrueTrace concept testbatch


Erledigt, Sir — ich habe weitergebaut und diesmal **nur einen** klar gelabelten Audio+Caption Preview erzeugt. Keine 3x gleiche Telegram-Kachel, wir sind ja nicht bei einer schlechten NFT-Demo.

## Neu gebaut: EdgeTTS + Caption Burn-in Pipeline

Neue Module:

```text
autoshorts/audio/edge_tts_adapter.py
autoshorts/audio/final_preview.py
```

Neue Tests:

```text
tests/test_edge_tts_adapter.py
tests/test_audio_caption_preview.py
```

Commit:

```text
3f68001 feat: add EdgeTTS audio caption preview
```

## Was jetzt funktioniert

Pipeline:

```text
TrueTrace Candidate
→ ScriptDraft
→ SubtitleTrack
→ EdgeTTS MP3 Voiceover
→ ASS Subtitle File
→ FFmpeg Audio + Caption Burn-in
→ Audio/Caption Review MP4
```

## EdgeTTS Adapter

Neue Funktion:

```python
synthesize_edge_tts()
```

Sie ruft explizit:

```text
edge-tts --voice en-GB-RyanNeural --text ... --write-media voice.mp3
```

und meldet sauber:

```text
side_effects = ("call_edge_tts", "write_audio")
external_calls = ("edge-tts",)
```

Also kein versteckter API-Zauber, sondern eine klare Boundary.

## Audio+Caption Renderer

Neue Funktionen:

```python
build_audio_caption_preview_plan()
render_audio_caption_preview()
```

Sie erzeugen:

```text
final-preview.ass
audio-caption-preview.mp4
```

mit:

- EdgeTTS Audio
- renderer-owned ASS subtitles
- FFmpeg Burn-in
- kein Upload
- kein Telegram Call
- kein Platform Call

## Tatsächlich erzeugter erster Review Clip

Candidate:

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

Output:

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

Voice file:

```text
data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/voice.mp3
```

Subtitle file:

```text
data/previews/truetrace-audio-caption-first/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview.ass
```

## Verifiziert

FFprobe:

```text
Video: h264, 540x960, duration 38.04s
Audio: aac, duration 28.85s
```

Ich habe bewusst **nicht** `-shortest` verwendet, damit das Video nicht bei Ende der Voiceover-Datei abgeschnitten wird. Der erste Versuch hätte sonst den 38s Preview auf 28.8s gekürzt. Natürlich hätte FFmpeg das ohne Scham getan — Maschinen haben keine Erziehung.

## Tests

Full Suite:

```text
154 passed in 31.65s
```

## Aktueller Commit-Verlauf

```text
3f68001 feat: add EdgeTTS audio caption preview
066201d feat: attach audio plans to TrueTrace previews
30a8129 feat: add subtitle and tts planning
6efc4dc feat: add TrueTrace top preview packages
2d6af9f feat: add TrueTrace concept testbatch
```

## Nächster sinnvoller Schritt

Jetzt sollten wir als nächstes den Clip qualitativ verbessern:

```text
1. Voiceover Text kürzen/verdichten
2. Subtitle Timing an tatsächliche Audio-Länge anpassen
3. Motion Preview weniger statisch machen
4. Danach erst Top-3 Audio+Caption Batch bauen
```

Wichtig: Der aktuelle Clip beweist die technische Kette. Er ist noch kein schöner finaler Short. Aber jetzt haben wir die Basis: Stimme, Captions, Burn-in, Review-MP4.

Explanation:
**Finalizing media delivery**

I need to decide if I should send media for the user. They asked to continue, so I might include a final preview media while keeping in mind the previous issue of duplicates. It’s important to ensure I send only one, clearly labeled. I should verify the media path as well for Telegram's delivery, avoiding unnecessary details. I want the final output to be concise, mentioning the actual generated audio caption preview without sending duplicates.


[M G] Ok mach weiter.


Explanation:
**Planning video improvements**

I need to keep going with the project and ensure I have the right tools ready. I think a systematic approach could be beneficial. The tasks include shortening the voiceover and aligning the subtitles with the timing. I’ll inspect the files using a test-driven development (TDD) approach. Maybe I should build a finalized script module, and I need to implement a voiceover finalizer while aligning subtitle duration to the audio using ffprobe. Let's aim to produce an improved clip!


test-driven-development

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

# Test-Driven Development (TDD)

## Overview

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

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

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

## When to Use

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

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

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

## The Iron Law

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

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

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

Implement fresh from tests. Period.

## Red-Green-Refactor Cycle

### RED — Write Failing Test

Write one minimal test showing what should happen.

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

    result = retry_operation(operation)

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

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

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

### Verify RED — Watch It Fail

**MANDATORY. Never skip.**

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

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

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

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

### GREEN — Minimal Code

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

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

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

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

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

We'll fix it in REFACTOR.

### Verify GREEN — Watch It Pass

**MANDATORY.**

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

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

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

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

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

### REFACTOR — Clean Up

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

Keep tests green throughout. Don't add behavior.

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

### Repeat

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

## Why Order Matters

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

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

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

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

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

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

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

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

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

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

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

"Pragmatic" shortcuts = debugging in production = slower.

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

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

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

## Common Rationalizations

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

## Red Flags — STOP and Start Over

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

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

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

## Verification Checklist

Before marking work complete:

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

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

## When Stuck

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

## Hermes Agent Integration

### Running Tests

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

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

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

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

### CLI Entry Points

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

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

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

### With delegate_task

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

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

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

### With systematic-debugging

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

Never fix bugs without a test.

## Demo / Fixture Features

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

## Side-Effect-Free Integration Boundaries

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

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

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

### Media / Rendering Pipeline Boundaries

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

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

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

### Approval-First Media/Social Pipelines

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

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

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

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

## Testing Anti-Patterns

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

## Final Rule

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

No exceptions without the user's explicit permission.

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


edge-tts

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

# Edge TTS

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

## When to Use

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

## Prerequisites

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

## User Voice Preference

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

## German Voices Available

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

## Usage

### Basic Synthesis

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

### Testing a Voice

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

### Listing Available Voices

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

### Configuring in Hermes

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

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

## Pitfalls

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

## Shorts / AutoShorts Baseline Voice Use

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

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

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

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

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

Then mux/burn captions in a separate FFmpeg boundary:

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

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

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

## Linked Resources

- `references/german-voice-comparison.md` — German male voice comparison data from session 2026-05-07, with recommendations for voice selection.

## Voice Switching Workflow

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

## Related Skills

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


social-media-shorts-automation

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

# Social-Media Shorts Automation

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

## Core principle

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

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

## Retention-Studio / Mechanism-Reveal Pipeline

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

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

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

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

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

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

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

## Recommended workflow

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

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

3. **Use hook-first, retention-led scripting**
   - First 1–2 seconds must contain a clear tension, identity statement, pain point, contrarian line, or immediate self-recognition.
   - Prefer one idea per video over generic “4 tips” unless the list format is the explicit series.
   - Avoid generic advice viewers have already heard unless framed with a new angle, concrete payoff, or strong story.
   - Reward hooks with direct address, specificity, open loops, tension, and a visible payoff; reject slow intros such as “In this video...”.
   - See `references/retention-led-shorts.md` for scoring signals, penalties, and voiceover finalization rules.

4. **Quality gate before rendering**
   Score drafts on:
   - hook strength
   - novelty
   - emotional pull
   - clarity
   - retention risk
   - comment/share potential
   - AI-slop risk
   - platform/policy risk
   Render only drafts above the chosen threshold, e.g. 8/10.

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

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

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

8. **TTS / voice layer after draft approval**
   - Treat TTS as a later layer, not the starting point for a first post draft.
   - Decide voice, final voiceover script, words-per-second, scene timing, caption sync, audio format, provider, and separate voice approval only after the social draft package is reviewable.
   - This avoids spending TTS/rendering effort on unapproved hooks, captions, or platform positioning.

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

## Platform duration/monetization notes

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

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

## Project/repo hygiene for automated video bots

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

## Sensitive-content positioning

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

## Pitfalls

- Do not send several generated preview videos as unlabeled media attachments just because they are distinct files. Telegram/mobile previews can make timing-card MP4s look identical. Verify uniqueness (path + title + hash/duration) and provide labels or an indexed summary; if the user complains about duplicate-looking media, stop resending, verify artifacts, and continue the pipeline.
- Do not over-optimize renderer quality before testing whether the content format earns retention.
- Do not let German approval previews become unreadable technical cards: use pixel-width-aware wrapping, readable fonts, limited line counts, and visual checks for clipped/overcrowded text.
- Do not confuse approval language with final audience language. For this user, approval previews are German by default; final social videos remain English unless explicitly changed.
- Do not copy public automation workflows wholesale: keep their queue/status/cost tracking and template structure, but remove blind auto-publishing until a human approval/version lock exists.
- Do not mass-generate repetitive shorts without originality/inauthentic-content gates; this can harm monetization/account health and create AI slop.
- Do not jump directly from a script idea to ComfyUI/Wan/Qwen/Image/video generation. First build and inspect the content-to-review spine so weak hooks, generic scripts, policy issues, and static visual concepts are killed before render time.
- Do not create a parallel candidate path for content experiments; convert content artifacts into the existing candidate factory input so IDs, hashtags, duration classes, quality gates, and pillar/platform validation stay centralized.
- Do not use `x or default` for optional content/script overrides in factories; explicit blank strings and zero durations must pass through to validation so tests can catch them.
- Do not assume TikTok monetization rules match YouTube: TikTok Creator Rewards emphasizes 1min+ eligible videos, while shorter videos may still be better for growth.
- Do not publish religious or health-adjacent content with overconfident claims; preserve humility, nuance, and platform safety.
- Do not rely on platform payout as the only monetization path; plan indirect monetization such as newsletter, templates, affiliates, digital products, or services.


Explanation:
**Refining rendering settings**

I'm working on the final renderer, making sure to set video_mode to "trim_to_audio" with the -shortest option. I think it’s necessary to test and not use it blindly, focusing on when to apply it explicitly. Also, I need to modify final_preview to support duration_mode, with a default of preserving video without the -shortest. My goal is to generate a better first clip that keeps the visual length at 38 seconds while trimming to the audio length when needed.

from autoshorts.audio.voiceover import (
    FinalVoiceoverScript,
    build_truetrace_final_voiceover,
    validate_final_voiceover_script,
)

def test_build_truetrace_final_voiceover_for_quiet_rot_is_concise_and_retention_led():
    script = build_truetrace_final_voiceover("truetrace-ai-automation-quiet-rot-38s")

    assert script.candidate_id == "truetrace-ai-automation-quiet-rot-38s"
    assert script.language == "en"
    assert script.voice == "en-GB-RyanNeural"
    assert script.target_duration_seconds == 24
    assert len(script.text.split()) <= 70
    assert script.text.startswith("Most AI automations do not fail dramatically.")
    assert "exception path" in script.text
    assert "Follow" not in script.text
    assert script.side_effects == ()
    assert script.external_calls == ()

def test_build_truetrace_final_voiceover_has_all_top3_scripts_with_stable_duration():
    ids = (
        "truetrace-ai-automation-quiet-rot-38s",
        "truetrace-prompt-vs-system-workflow-breaks-35s",
        "truetrace-ai-notes-equal-importance-34s",
    )

    scripts = tuple(build_truetrace_final_voiceover(candidate_id) for candidate_id in ids)

    assert [script.target_duration_seconds for script in scripts] == [24, 23, 22]
    assert all(45 <= len(script.text.split()) <= 70 for script in scripts)
    assert all(script.text.count(".") >= 3 for script in scripts)

def test_validate_final_voiceover_script_rejects_bad_language_duration_and_filler():
    script = FinalVoiceoverScript(
        candidate_id="candidate",
        text="In this video we will unlock your full potential with a very long and generic intro.",
        language="de",
        voice="en-GB-RyanNeural",
        target_duration_seconds=5,
    )

    validation = validate_final_voiceover_script(script)

    assert validation.is_valid is False
    assert "final voiceover language must be en" in validation.errors
    assert "target_duration_seconds must be between 15 and 45" in validation.errors
    assert "voiceover contains generic intro/filler language" in validation.errors


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


patch

from autoshorts.audio.final_preview import (
    AudioCaptionPreviewPlan,
    build_audio_caption_preview_plan,
    render_audio_caption_preview,
    validate_audio_caption_preview_plan,
)


def test_build_audio_caption_preview_plan_can_explicitly_trim_to_audio(tmp_path):
    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)

    plan = build_audio_caption_preview_plan(
        video_path=tmp_path / "timing.mp4",
        audio_path=tmp_path / "voice.mp3",
        subtitle_track=track,
        output_path=tmp_path / "final.mp4",
        duration_mode="trim_to_audio",
    )

    assert plan.duration_mode == "trim_to_audio"
    assert plan.side_effects == ()
    assert plan.external_calls == ()

def test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg(tmp_path):


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.py
@@ -56,6 +56,22 @@
     assert "audio caption preview plans must not have side effects" in validation.errors

+def test_build_audio_caption_preview_plan_can_explicitly_trim_to_audio(tmp_path):
+    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)
+
+    plan = build_audio_caption_preview_plan(
+        video_path=tmp_path / "timing.mp4",
+        audio_path=tmp_path / "voice.mp3",
+        subtitle_track=track,
+        output_path=tmp_path / "final.mp4",
+        duration_mode="trim_to_audio",
+    )
+
+    assert plan.duration_mode == "trim_to_audio"
+    assert plan.side_effects == ()
+    assert plan.external_calls == ()
+
+
 def test_render_audio_caption_preview_writes_ass_and_invokes_ffmpeg(tmp_path):
     input_video = tmp_path / "timing.mp4"
     audio = tmp_path / "voice.mp3"

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


    assert "-shortest" not in calls[0]
    assert result.output_path == str(output)


from pathlib import Path
import subprocess

from autoshorts.audio.final_preview import (
    AudioCaptionPreviewPlan,
    build_audio_caption_preview_plan,
    render_audio_caption_preview,
    validate_audio_caption_preview_plan,
)
from autoshorts.audio.subtitles import build_hormozi_subtitle_track

def test_build_audio_caption_preview_plan_is_side_effect_free_and_writes_ass_content(tmp_path):
    video_path = tmp_path / "timing.mp4"
    audio_path = tmp_path / "voice.mp3"
    track = build_hormozi_subtitle_track("Most AI automations quietly rot when workflows lack boundaries", total_duration_seconds=5)

    plan = build_audio_caption_preview_plan(
        video_path=video_path,
        audio_path=audio_path,
        subtitle_track=track,
        output_path=tmp_path / "final-preview.mp4",
    )

    assert plan.video_path == str(video_path)
    assert plan.audio_path == str(audio_path)
    assert plan.output_path.endswith("final-preview.mp4")
    assert plan.subtitle_file_path.endswith("final-preview.ass")
    assert plan.duration_mode == "preserve_video"
    assert "[Script Info]" in plan.subtitle_file_content
    assert "Dialogue:" in plan.subtitle_file_content
    assert "MOST AI AUTOMATIONS" in plan.subtitle_file_content
    assert plan.side_effects == ()
    assert plan.external_calls == ()

def test_validate_audio_caption_preview_plan_rejects_bad_paths_and_unsafe_side_effects(tmp_path):
    track = build_hormozi_subtitle_track("valid subtitle text", total_duration_seconds=2)
    plan = AudioCaptionPreviewPlan(
        video_path="video.mov",
        audio_path="voice.wav",
        output_path="out.avi",
        subtitle_file_path="out.srt",
        subtitle_file_content="",
        subtitle_track=track,
        duration_mode="shortest",
        side_effects=("write_file",),
        external_calls=("ffmpeg",),
    )

    validation = validate_audio_caption_preview_plan(plan)

    assert validation.is_valid is False
    assert "video_path must end with .mp4" in validation.errors
    assert "audio_path must end with .mp3" in validation.errors
    assert "output_path must end with .mp4" in validation.errors
    assert "subtitle_file_path must end with .ass" in validation.errors
    assert "duration_mode must be preserve_video or trim_to_audio" in validation.errors
    assert "audio caption preview plans must not have side effects" in validation.errors

def test_build_audio_caption_preview_plan_can_explicitly_trim_to_audio(tmp_path):
    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)

    plan = build_audio_caption_preview_plan(
        video_path=tmp_path / "timing.mp4",
        audio_path=tmp_path / "voice.mp3",
        subtitle_track=track,
        output_path=tmp_path / "final.mp4",
        duration_mode="trim_to_audio",
    )

    assert plan.duration_mode == "trim_to_audio"
    assert plan.side_effects == ()
    assert plan.external_calls == ()

def test_render_audio_caption_preview_preserves_video_duration_by_default(tmp_path):
    input_video = tmp_path / "timing.mp4"
    audio = tmp_path / "voice.mp3"
    output = tmp_path / "final.mp4"
    input_video.write_bytes(b"fake video")
    audio.write_bytes(b"fake audio")
    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)
    plan = build_audio_caption_preview_plan(
        video_path=input_video,
        audio_path=audio,
        subtitle_track=track,
        output_path=output,
    )
    calls = []

    def fake_runner(command, *, capture_output, text, check):
        calls.append(command)
        output.write_bytes(b"fake final video")
        return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")

    result = render_audio_caption_preview(plan, runner=fake_runner)

    assert Path(plan.subtitle_file_path).exists()
    assert output.exists()
    assert calls[0][:5] == ["ffmpeg", "-y", "-i", str(input_video), "-i"]
    assert "ass=" in " ".join(calls[0])
    assert "-shortest" not in calls[0]
    assert result.output_path == str(output)
    assert result.duration_mode == "preserve_video"
    assert result.side_effects == ("write_subtitles", "call_ffmpeg", "write_mp4")
    assert result.external_calls == ("ffmpeg",)

def test_render_audio_caption_preview_trims_only_when_explicit(tmp_path):
    input_video = tmp_path / "timing.mp4"
    audio = tmp_path / "voice.mp3"
    output = tmp_path / "final.mp4"
    input_video.write_bytes(b"fake video")
    audio.write_bytes(b"fake audio")
    track = build_hormozi_subtitle_track("Prompt is not system workflow boundaries matter", total_duration_seconds=4)
    plan = build_audio_caption_preview_plan(
        video_path=input_video,
        audio_path=audio,
        subtitle_track=track,
        output_path=output,
        duration_mode="trim_to_audio",
    )
    calls = []

    def fake_runner(command, *, capture_output, text, check):
        calls.append(command)
        output.write_bytes(b"fake final video")
        return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")

    result = render_audio_caption_preview(plan, runner=fake_runner)

    assert "-shortest" in calls[0]
    assert result.duration_mode == "trim_to_audio"


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_caption_preview.py">
ERROR [29:17] Cannot access attribute "duration_mode" for class "AudioCaptionPreviewPlan"
  Attribute "duration_mode" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [46:9] No parameter named "duration_mode" [reportCallIssue] (Pyright)
ERROR [106:19] Cannot access attribute "duration_mode" for class "AudioCaptionPreviewResult"
  Attribute "duration_mode" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [123:9] No parameter named "duration_mode" [reportCallIssue] (Pyright)
ERROR [135:19] Cannot access attribute "duration_mode" for class "AudioCaptionPreviewResult"
  Attribute "duration_mode" is unknown [reportAttributeAccessIssue] (Pyright)
</diagnostics>


terminal

pytest tests/test_voiceover.py tests/test_audio_caption_preview.py -q