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


"""Final short-form voiceover scripts for TrueTrace review renders."""

from __future__ import annotations

from dataclasses import dataclass

from autoshorts.ideas.candidate import ValidationResult

_GENERIC_FILLER = (
    "in this video",
    "unlock your full potential",
    "game changer",
    "you won't believe",
)

@dataclass(frozen=True)
class FinalVoiceoverScript:
    candidate_id: str
    text: str
    language: str
    voice: str
    target_duration_seconds: int
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

_SCRIPTS: dict[str, tuple[str, int]] = {
    "truetrace-ai-automation-quiet-rot-38s": (
        "Most AI automations do not fail dramatically. They quietly rot. "
        "Day one looks clean, because the demo input behaves. By day three, the real input changes. "
        "Now the workflow needs an exception path. Normal goes forward. Weird goes to review. Unclear stops. "
        "If your automation has no exception path, it is not a system. It is a lucky prompt.",
        24,
    ),
    "truetrace-prompt-vs-system-workflow-breaks-35s": (
        "A smart prompt can still build a fragile workflow. The prompt is only one part. "
        "A system needs an input boundary, an output standard, a review step, and an action boundary. "
        "Without those, the same prompt behaves differently every week. Do not ask for a better prompt first. "
        "Ask what the workflow must refuse, review, and repeat.",
        23,
    ),
    "truetrace-ai-notes-equal-importance-34s": (
        "AI notes fail when everything looks equally important. A pretty summary is not a decision system. "
        "Separate facts from decisions, risks, open questions, and next actions. "
        "Now the note tells you what changed, what matters, and what needs a human. "
        "Do not ask AI for more notes. Ask it for decision structure.",
        22,
    ),
}

def build_truetrace_final_voiceover(candidate_id: str) -> FinalVoiceoverScript:
    if candidate_id not in _SCRIPTS:
        raise ValueError(f"unknown TrueTrace candidate id: {candidate_id}")
    text, duration = _SCRIPTS[candidate_id]
    script = FinalVoiceoverScript(
        candidate_id=candidate_id,
        text=text,
        language="en",
        voice="en-GB-RyanNeural",
        target_duration_seconds=duration,
    )
    validation = validate_final_voiceover_script(script)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    return script

def validate_final_voiceover_script(script: FinalVoiceoverScript) -> ValidationResult:
    errors: list[str] = []
    if not script.candidate_id.strip():
        errors.append("candidate_id is required")
    if not script.text.strip():
        errors.append("text is required")
    if script.language != "en":
        errors.append("final voiceover language must be en")
    if not script.voice.strip():
        errors.append("voice is required")
    if not (15 <= script.target_duration_seconds <= 45):
        errors.append("target_duration_seconds must be between 15 and 45")
    if any(phrase in script.text.casefold() for phrase in _GENERIC_FILLER):
        errors.append("voiceover contains generic intro/filler language")
    if len(script.text.split()) > 80:
        errors.append("voiceover must stay under 80 words")
    if script.side_effects:
        errors.append("voiceover script must not have side effects")
    if script.external_calls:
        errors.append("voiceover script must not have external calls")
    return ValidationResult(is_valid=not errors, errors=tuple(errors))


patch

class AudioCaptionPreviewPlan:
    video_path: str
    audio_path: str
    output_path: str
    subtitle_file_path: str
    subtitle_file_content: str
    subtitle_track: SubtitleTrack
    duration_mode: str = "preserve_video"
    side_effects: tuple[str, ...] = ()

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, ...] = ()


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -21,6 +21,7 @@
     subtitle_file_path: str
     subtitle_file_content: str
     subtitle_track: SubtitleTrack
+    duration_mode: str = "preserve_video"
     side_effects: tuple[str, ...] = ()
     external_calls: tuple[str, ...] = ()


class AudioCaptionPreviewResult:
    output_path: str
    subtitle_file_path: str
    returncode: int
    stdout: str
    stderr: str
    duration_mode: str = "preserve_video"
    side_effects: tuple[str, ...] = ("write_subtitles", "call_ffmpeg", "write_mp4")

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")


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -33,6 +33,7 @@
     returncode: int
     stdout: str
     stderr: str
+    duration_mode: str = "preserve_video"
     side_effects: tuple[str, ...] = ("write_subtitles", "call_ffmpeg", "write_mp4")
     external_calls: tuple[str, ...] = ("ffmpeg",)


    *,
    video_path: str | Path,
    audio_path: str | Path,
    subtitle_track: SubtitleTrack,
    output_path: str | Path,
    duration_mode: str = "preserve_video",
) -> AudioCaptionPreviewPlan:

    *,
    video_path: str | Path,
    audio_path: str | Path,
    subtitle_track: SubtitleTrack,
    output_path: str | Path,
) -> AudioCaptionPreviewPlan:


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -102,6 +102,7 @@
     audio_path: str | Path,
     subtitle_track: SubtitleTrack,
     output_path: str | Path,
+    duration_mode: str = "preserve_video",
 ) -> AudioCaptionPreviewPlan:
     out = Path(output_path)
     plan = AudioCaptionPreviewPlan(


        subtitle_file_content=render_ass_subtitle_file(subtitle_track),
        subtitle_track=subtitle_track,
        duration_mode=duration_mode,
        side_effects=(),

        subtitle_file_content=render_ass_subtitle_file(subtitle_track),
        subtitle_track=subtitle_track,
        side_effects=(),


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -112,6 +112,7 @@
         subtitle_file_path=str(out.with_suffix(".ass")),
         subtitle_file_content=render_ass_subtitle_file(subtitle_track),
         subtitle_track=subtitle_track,
+        duration_mode=duration_mode,
         side_effects=(),
         external_calls=(),
     )


    if plan.subtitle_file_path and not plan.subtitle_file_path.endswith(".ass"):
        errors.append("subtitle_file_path must end with .ass")
    if plan.duration_mode not in {"preserve_video", "trim_to_audio"}:
        errors.append("duration_mode must be preserve_video or trim_to_audio")
    if plan.side_effects:

    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:


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -141,6 +141,8 @@
         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.duration_mode not in {"preserve_video", "trim_to_audio"}:
+        errors.append("duration_mode must be preserve_video or trim_to_audio")
     if plan.side_effects:
         errors.append("audio caption preview plans must not have side effects")
     if plan.external_calls:


def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
    command = [
        "ffmpeg",

def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
    return [
        "ffmpeg",


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -157,7 +157,7 @@

 def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
-    return [
+    command = [
         "ffmpeg",
         "-y",
         "-i",

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py">
ERROR [159:48] Function with declared return type "list[str]" must return value on all code paths
  "None" is not assignable to "list[str]" [reportReturnType] (Pyright)
</diagnostics>


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

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


   150|    errors.extend(validate_subtitle_track(plan.subtitle_track).errors)
   151|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   152|
   153|
   154|def _ass_filter_path(path: str) -> str:
   155|    # Avoid shell quoting entirely by passing argv; escape colon/backslash for FFmpeg filter parser.
   156|    return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")
   157|
   158|
   159|def _command(plan: AudioCaptionPreviewPlan) -> list[str]:
   160|    command = [
   161|        "ffmpeg",
   162|        "-y",
   163|        "-i",
   164|        plan.video_path,
   165|        "-i",
   166|        plan.audio_path,
   167|        "-vf",
   168|        f"ass='{_ass_filter_path(plan.subtitle_file_path)}'",
   169|        "-map",
   170|        "0:v:0",
   171|        "-map",
   172|        "1:a:0",
   173|        "-c:v",
   174|        "libx264",
   175|        "-c:a",
   176|        "aac",
   177|        "-movflags",
   178|        "+faststart",
   179|        plan.output_path,
   180|    ]
   181|
   182|
   183|def render_audio_caption_preview(plan: AudioCaptionPreviewPlan, *, runner: Runner = subprocess.run) -> AudioCaptionPreviewResult:
   184|    """Write ASS subtitles and call FFmpeg to mux voiceover + burn captions into an MP4."""
   185|
   186|    validation = validate_audio_caption_preview_plan(plan)
   187|    if not validation.is_valid:
   188|        raise ValueError("; ".join(validation.errors))
   189|    if not Path(plan.video_path).exists():
   190|        raise FileNotFoundError(plan.video_path)
   191|    if not Path(plan.audio_path).exists():
   192|        raise FileNotFoundError(plan.audio_path)
   193|
   194|    subtitle_path = Path(plan.subtitle_file_path)
   195|    output_path = Path(plan.output_path)
   196|    subtitle_path.parent.mkdir(parents=True, exist_ok=True)
   197|    output_path.parent.mkdir(parents=True, exist_ok=True)
   198|    subtitle_path.write_text(plan.subtitle_file_content)
   199|
   200|    completed = runner(_command(plan), capture_output=True, text=True, check=False)
   201|    if completed.returncode != 0:
   202|        raise RuntimeError(completed.stderr or "ffmpeg audio/caption preview failed")
   203|    if not output_path.exists():
   204|        raise RuntimeError("ffmpeg completed but did not create output video")
   205|
   206|    return AudioCaptionPreviewResult(
   207|        output_path=str(output_path),
   208|        subtitle_file_path=str(subtitle_path),
   209|        returncode=completed.returncode,
   210|

Use offset=210 to continue reading (showing 150-209 of 214 lines)


        "-c:a",
        "aac",
    ]
    if plan.duration_mode == "trim_to_audio":
        command.append("-shortest")
    command.extend([
        "-movflags",
        "+faststart",
        plan.output_path,
    ])
    return command

        "-c:a",
        "aac",
        "-movflags",
        "+faststart",
        plan.output_path,
    ]


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -174,10 +174,15 @@
         "libx264",
         "-c:a",
         "aac",
+    ]
+    if plan.duration_mode == "trim_to_audio":
+        command.append("-shortest")
+    command.extend([
         "-movflags",
         "+faststart",
         plan.output_path,
-    ]
+    ])
+    return command

 def render_audio_caption_preview(plan: AudioCaptionPreviewPlan, *, runner: Runner = subprocess.run) -> AudioCaptionPreviewResult:

/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


   206|    if completed.returncode != 0:
   207|        raise RuntimeError(completed.stderr or "ffmpeg audio/caption preview failed")
   208|    if not output_path.exists():
   209|        raise RuntimeError("ffmpeg completed but did not create output video")
   210|
   211|    return AudioCaptionPreviewResult(
   212|        output_path=str(output_path),
   213|        subtitle_file_path=str(subtitle_path),
   214|        returncode=completed.returncode,
   215|        stdout=completed.stdout,
   216|        stderr=completed.stderr,
   217|        side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),
   218|        external_calls=("ffmpeg",),
   219|    )
   220|


        stdout=completed.stdout,
        stderr=completed.stderr,
        duration_mode=plan.duration_mode,
        side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),

        stdout=completed.stdout,
        stderr=completed.stderr,
        side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/audio/final_preview.py
@@ -214,6 +214,7 @@
         returncode=completed.returncode,
         stdout=completed.stdout,
         stderr=completed.stderr,
+        duration_mode=plan.duration_mode,
         side_effects=("write_subtitles", "call_ffmpeg", "write_mp4"),
         external_calls=("ffmpeg",),
     )


terminal

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


Output:
........                                                                 [100%]
8 passed in 0.05s


Explanation:
**Improving clip integration**

I need to focus on producing an improved clip by integrating a new voiceover and adjusting the subtitle duration. I'm thinking that the target duration might not perfectly match the actual length from Edge TTS, which could be longer than expected. To make sure subtitles fit well, I can aim for a voice target duration of 24 seconds, although the actual might end up being around 26 seconds. I'll need to check this using the terminal for mandatory duration settings before generating the final version.

python - <<'PY'
from pathlib import Path
from autoshorts.demo.truetrace_preview_packages import build_top_truetrace_preview_packages
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
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-v2').resolve()
result = build_top_truetrace_preview_packages(output_dir=out_dir, approval_language='de')
artifact = result.artifacts[0]
voiceover = build_truetrace_final_voiceover(artifact.candidate.candidate_id)
voice_path = out_dir / artifact.candidate.candidate_id / 'voice-final.mp3'
final_path = out_dir / artifact.candidate.candidate_id / 'audio-caption-preview-v2.mp4'
voice = synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=artifact.tts_plan, output_path=str(voice_path)))
subtitle_track = build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=voiceover.target_duration_seconds)
plan = build_audio_caption_preview_plan(
    video_path=artifact.package.preview_video_path,
    audio_path=voice.output_path,
    subtitle_track=subtitle_track,
    output_path=final_path,
    duration_mode='trim_to_audio',
)
rendered = render_audio_caption_preview(plan)
print('candidate', artifact.candidate.candidate_id)
print('words', len(voiceover.text.split()))
print('target_duration', voiceover.target_duration_seconds)
print('voice', voice.output_path)
print('final', 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-v2/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v2.mp4
sha256sum data/previews/truetrace-audio-caption-v2/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v2.mp4


Output:
candidate truetrace-ai-automation-quiet-rot-38s
words 59
target_duration 24
voice /home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-audio-caption-v2/truetrace-ai-automation-quiet-rot-38s/voice-final.mp3
final /home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-audio-caption-v2/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v2.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=29.083333
codec_name=aac
codec_type=audio
duration=29.112000
452ead4dacb7be11d0e0e598c41b8b84e718de1e5424b5a9d4082e1c038504ac  data/previews/truetrace-audio-caption-v2/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v2.mp4


Explanation:
**Evaluating subtitle issues**

I’m looking into subtitles for a clip that's 29 seconds long, but they're only 24 seconds, which isn't right. I think I need to implement an audio probe to rebuild the track's actual duration. Maybe I can do this quickly, but I feel like tests are essential. I might generate a probe module and test it with a fake runner. Also, I could consider using ffprobe in the terminal to check the actual duration, and then rerender it. I just need to keep everything aligned with TDD and write the necessary tests.

import subprocess

from autoshorts.audio.probe import probe_media_duration_seconds

def test_probe_media_duration_seconds_parses_ffprobe_output():
    calls = []

    def fake_runner(command, *, capture_output, text, check):
        calls.append(command)
        return subprocess.CompletedProcess(command, 0, stdout="29.112000\n", stderr="")

    duration = probe_media_duration_seconds("voice.mp3", runner=fake_runner)

    assert duration == 29.112
    assert calls == [[
        "ffprobe",
        "-v",
        "error",
        "-show_entries",
        "format=duration",
        "-of",
        "default=noprint_wrappers=1:nokey=1",
        "voice.mp3",
    ]]

def test_probe_media_duration_seconds_rejects_invalid_output():
    def fake_runner(command, *, capture_output, text, check):
        return subprocess.CompletedProcess(command, 0, stdout="not-a-duration\n", stderr="")

    try:
        probe_media_duration_seconds("voice.mp3", runner=fake_runner)
    except RuntimeError as exc:
        assert "could not parse media duration" in str(exc)
    else:
        raise AssertionError("expected RuntimeError")


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


Output:
==================================== ERRORS ====================================
__________________ ERROR collecting tests/test_audio_probe.py __________________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_audio_probe.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_probe.py:3: in <module>
    from autoshorts.audio.probe import probe_media_duration_seconds
E   ModuleNotFoundError: No module named 'autoshorts.audio.probe'
=========================== short test summary info ============================
ERROR tests/test_audio_probe.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.11s


"""Small FFprobe helpers for audio/video review artifacts."""

from __future__ import annotations

from collections.abc import Callable
import subprocess

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

def probe_media_duration_seconds(path: str, *, runner: Runner = subprocess.run) -> float:
    """Return media duration in seconds via ffprobe."""

    command = [
        "ffprobe",
        "-v",
        "error",
        "-show_entries",
        "format=duration",
        "-of",
        "default=noprint_wrappers=1:nokey=1",
        path,
    ]
    completed = runner(command, capture_output=True, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr or "ffprobe failed")
    try:
        duration = float(completed.stdout.strip())
    except ValueError as exc:
        raise RuntimeError("could not parse media duration") from exc
    if duration <= 0:
        raise RuntimeError("media duration must be positive")
    return duration


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


Output:
..........                                                               [100%]
10 passed in 0.07s


python - <<'PY'
from pathlib import Path
from math import ceil
from autoshorts.demo.truetrace_preview_packages import build_top_truetrace_preview_packages
from autoshorts.audio.voiceover import build_truetrace_final_voiceover
from autoshorts.audio.subtitles import build_hormozi_subtitle_track
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
from autoshorts.audio.probe import probe_media_duration_seconds

out_dir = Path('data/previews/truetrace-audio-caption-v3').resolve()
result = build_top_truetrace_preview_packages(output_dir=out_dir, approval_language='de')
artifact = result.artifacts[0]
voiceover = build_truetrace_final_voiceover(artifact.candidate.candidate_id)
voice_path = out_dir / artifact.candidate.candidate_id / 'voice-final.mp3'
final_path = out_dir / artifact.candidate.candidate_id / 'audio-caption-preview-v3.mp4'
voice = synthesize_edge_tts(EdgeTTSRequest(text=voiceover.text, tts_plan=artifact.tts_plan, output_path=str(voice_path)))
audio_duration = probe_media_duration_seconds(voice.output_path)
subtitle_track = build_hormozi_subtitle_track(voiceover.text, total_duration_seconds=ceil(audio_duration))
plan = build_audio_caption_preview_plan(
    video_path=artifact.package.preview_video_path,
    audio_path=voice.output_path,
    subtitle_track=subtitle_track,
    output_path=final_path,
    duration_mode='trim_to_audio',
)
rendered = render_audio_caption_preview(plan)
print('candidate', artifact.candidate.candidate_id)
print('words', len(voiceover.text.split()))
print('target_duration', voiceover.target_duration_seconds)
print('actual_audio_duration', f'{audio_duration:.3f}')
print('subtitle_duration', subtitle_track.cues[-1].end_ms / 1000)
print('voice', voice.output_path)
print('final', 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-v3/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v3.mp4
sha256sum data/previews/truetrace-audio-caption-v3/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v3.mp4


Output:
candidate truetrace-ai-automation-quiet-rot-38s
words 59
target_duration 24
actual_audio_duration 29.112
subtitle_duration 30.0
voice /home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-audio-caption-v3/truetrace-ai-automation-quiet-rot-38s/voice-final.mp3
final /home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/previews/truetrace-audio-caption-v3/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v3.mp4
codec_name=h264
codec_type=video
width=540
height=960
duration=29.083333
codec_name=aac
codec_type=audio
duration=29.112000
6c71929420bd266b8569440feed9a8ca7dc392e3cead8cc55bca2dc33118c871  data/previews/truetrace-audio-caption-v3/truetrace-ai-automation-quiet-rot-38s/audio-caption-preview-v3.mp4


Explanation:
**Updating documentation**

I need to think about updating the README and pyproject files. This involves adding information about the modules and running full tests to ensure everything works smoothly. I'll also need to commit those changes afterward. Maybe I should consider including some media to enhance the documentation, too. Lastly, I might need to patch the documentation further if I find anything that needs clarification or improvement. Let's keep track of these tasks!

patch

- `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.audio.probe` — FFprobe duration helper used to align subtitle tracks to generated audio.
- `autoshorts.audio.voiceover` — concise final English TrueTrace voiceover scripts for the top concepts.
- `autoshorts.ideas.candidate` — structured video candidate model and validation.

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


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -25,6 +25,8 @@
 - `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.audio.probe` — FFprobe duration helper used to align subtitle tracks to generated audio.
+- `autoshorts.audio.voiceover` — concise final English TrueTrace voiceover scripts for the top concepts.
 - `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.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.
- `autoshorts.audio.voiceover.build_truetrace_final_voiceover()` provides concise English final voiceover scripts for the top TrueTrace concepts.
- `autoshorts.audio.probe.probe_media_duration_seconds()` reads the generated MP3 duration via FFprobe so subtitle cues can be rebuilt against the actual voiceover length, not merely the estimated target.
- `render_audio_caption_preview(duration_mode="trim_to_audio")` may use `-shortest`, but only when explicitly requested. The default remains `preserve_video` to avoid silent truncation.

## Caption pipeline

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


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/subtitle_tts_strategy.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/docs/subtitle_tts_strategy.md
@@ -60,6 +60,9 @@
 - `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.
+- `autoshorts.audio.voiceover.build_truetrace_final_voiceover()` provides concise English final voiceover scripts for the top TrueTrace concepts.
+- `autoshorts.audio.probe.probe_media_duration_seconds()` reads the generated MP3 duration via FFprobe so subtitle cues can be rebuilt against the actual voiceover length, not merely the estimated target.
+- `render_audio_caption_preview(duration_mode="trim_to_audio")` may use `-shortest`, but only when explicitly requested. The default remains `preserve_video` to avoid silent truncation.

 ## Caption pipeline

No linter for .md files


terminal

Output:
........................................................................ [ 44%]
........................................................................ [ 89%]
.................                                                        [100%]
161 passed in 32.72s


git status --short && git add autoshorts/audio/voiceover.py autoshorts/audio/probe.py autoshorts/audio/final_preview.py tests/test_voiceover.py tests/test_audio_probe.py tests/test_audio_caption_preview.py README.md docs/subtitle_tts_strategy.md && git commit -m "feat: align TrueTrace voiceover previews" && git status --short && git log --oneline -5


Output:
M README.md
 M autoshorts/audio/final_preview.py
 M docs/subtitle_tts_strategy.md
 M tests/test_audio_caption_preview.py
?? autoshorts/audio/probe.py
?? autoshorts/audio/voiceover.py
?? tests/test_audio_probe.py
?? tests/test_voiceover.py
[feat/core-approval-quality-gates b9b6423] feat: align TrueTrace voiceover previews
 8 files changed, 279 insertions(+), 3 deletions(-)
 create mode 100644 autoshorts/audio/probe.py
 create mode 100644 autoshorts/audio/voiceover.py
 create mode 100644 tests/test_audio_probe.py
 create mode 100644 tests/test_voiceover.py
b9b6423 feat: align TrueTrace voiceover previews
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


Erledigt, Sir.

Ich habe den nächsten Qualitäts-Slice gebaut: **kürzere finale Voiceover-Skripte + tatsächliche Audio-Dauer messen + Subtitles daran ausrichten + explizites Trim-to-Audio nur wenn gewünscht**.

## Neu gebaut

Neue Module:

```text
autoshorts/audio/voiceover.py
autoshorts/audio/probe.py
```

Erweitert:

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

Neue Tests:

```text
tests/test_voiceover.py
tests/test_audio_probe.py
```

Commit:

```text
b9b6423 feat: align TrueTrace voiceover previews
```

## Was verbessert wurde

### 1. Final Voiceover Scripts

Neue Funktion:

```python
build_truetrace_final_voiceover()
```

Für die Top-3 Konzepte gibt es jetzt kompakte finale English Voiceover-Texte.

Für den ersten Clip:

```text
Most AI automations do not fail dramatically. They quietly rot.
Day one looks clean, because the demo input behaves.
By day three, the real input changes.
Now the workflow needs an exception path.
Normal goes forward. Weird goes to review. Unclear stops.
If your automation has no exception path, it is not a system.
It is a lucky prompt.
```

Eigenschaften:

```text
Words: 59
Target duration: 24s
Language: en
Voice: en-GB-RyanNeural
No generic filler
No “Follow for...” CTA-Geschwurbel
```

### 2. Audio Duration Probe

Neue Funktion:

```python
probe_media_duration_seconds()
```

Sie liest per FFprobe die echte MP3-Dauer aus.

Warum? Weil EdgeTTS natürlich nicht exakt unsere Wunschdauer spricht. Es ist TTS, kein Schweizer Uhrwerk mit britischem Akzent.

Beim erzeugten Clip:

```text
Estimated target: 24s
Actual EdgeTTS audio: 29.112s
```

### 3. Subtitle Timing an echte Audio-Dauer angepasst

Vorher:

```text
Subtitle duration: estimated 24s
Audio duration: 29.112s
```

Jetzt:

```text
Subtitle duration: 30.0s
Audio duration: 29.112s
```

Damit laufen Captions nicht mehr zu früh aus.

### 4. `duration_mode`

Der Audio+Caption Renderer unterstützt jetzt:

```python
duration_mode="preserve_video"
```

Default. Kein `-shortest`, kein heimliches Abschneiden.

Und explizit:

```python
duration_mode="trim_to_audio"
```

Nur dann wird `-shortest` verwendet.

Das ist wichtig: FFmpeg darf schneiden, aber nur mit schriftlicher Genehmigung. Wie ein anständiger Bürokrat.

## Neuer verbesserter Review Clip

Candidate:

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

Output:

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

## Verifiziert

FFprobe:

```text
Video: h264, 540x960
Video duration: 29.083s
Audio: aac
Audio duration: 29.112s
```

SHA256:

```text
6c71929420bd266b8569440feed9a8ca7dc392e3cead8cc55bca2dc33118c871
```

## Tests

Full Suite:

```text
161 passed in 32.72s
```

## Aktueller Commit-Verlauf

```text
b9b6423 feat: align TrueTrace voiceover previews
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
```

## Nächster sinnvoller Schritt

Jetzt ist die Audio/Captions-Kette solide. Als Nächstes würde ich die visuelle Seite verbessern:

```text
static timing cards
→ simple motion renderer
→ kinetic background movement
→ scene progress bars
→ caption punch-in/pulse
→ mechanism diagram animation
```

Also weniger PowerPoint-mit-Puls, mehr echter Short.


[M G] Das voiceover und die subtitles decken sich nicht mit dem text im oberen feld… wirkt das ganze nicht zu abgehoben? Erreichen wir so auch die leite welche noch nocht soo voel erfahrung mit KI haben?


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/audio-caption-preview-pipeline.md` for the approval-first EdgeTTS + ASS subtitles + FFmpeg review-MP4 pipeline, including actual-audio-duration probing and the explicit `preserve_video` vs `trim_to_audio` duration-mode guardrail.

See `references/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.

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


Ja, Sir — **Ihre Kritik ist korrekt**.

## 1. Voiceover/Subtitles passen nicht zum oberen Feld

Das aktuelle Preview ist technisch korrekt, aber dramaturgisch unsauber:

```text
Oberes Feld / Card:
alte German approval/timing copy

Voiceover/Subtitles:
neuer English final voiceover text
```

Das erzeugt kognitive Reibung. Der Zuschauer sieht oben etwas anderes als er hört/liest. Für Review ist das schon störend; für echten Content wäre es tödlich genug, um einen kleinen Ausschuss einzuberufen.

## Fix

Wir brauchen **eine einzige Source of Truth** pro Clip:

```text
FinalVoiceoverScript
→ SubtitleTrack
→ Top Hook Text
→ Scene Card Text
→ Social Caption
```

Nicht:

```text
Candidate Concept Text
+ später anderer Voiceover Text
+ dritte Caption-Version
```

Also: Der Text im oberen Feld muss aus demselben finalen Voiceover/Hook kommen wie Subtitles und Audio.

---

## 2. Ja: aktuell wirkt es zu abgehoben

Der aktuelle Text ist gut für:

```text
AI builders
Automation nerds
Workflow-/Ops-Leute
Leute, die schon wissen, was ein “exception path” ist
```

Aber für breiteres Publikum ist es zu abstrakt.

Begriffe wie:

```text
exception path
input boundary
output standard
action boundary
decision structure
```

sind für Fachleute präzise — aber für Anfänger klingt es nach einem LinkedIn-Post mit Krawatte.

## Problem

Wir sagen aktuell:

```text
If your automation has no exception path, it is not a system.
```

Das ist stark, aber nicht sofort greifbar.

Für Anfänger besser:

```text
If your AI does not know what to do when something weird happens,
it will break the first time real life shows up.
```

Das ist derselbe Gedanke, aber menschlicher.

---

## 3. Zielgruppe: Wir sollten breiter anfangen

Für TrueTrace würde ich nicht mit „AI systems for experts“ starten, sondern mit:

```text
AI tools that work in real life
```

Also Zielgruppe:

```text
People using ChatGPT, Notion AI, Zapier, Make, email automations,
but not yet thinking like system architects.
```

Die haben ein echtes Problem:

```text
“It worked once, then it became unreliable.”
```

Das versteht jeder.

---

## Neue Copy-Richtung

Statt:

```text
Most AI automations do not fail dramatically. They quietly rot.
```

Besser für breite Zielgruppe:

```text
Your AI automation did not really break.
Real life just gave it an input it was never prepared for.
```

Oder noch einfacher:

```text
Your AI automation works in the demo.
Then real life shows up.
```

Das ist deutlich stärker für Anfänger.

## Neue Hook-Variante

```text
Your AI automation works perfectly…
until the first weird email arrives.
```

Das ist konkret. Jeder versteht „weird email“.

---

## Neuer Anfänger-freundlicher Voiceover-Entwurf

Für Clip 1 würde ich den Text so umbauen:

```text
Your AI automation works perfectly in the demo.

Then one weird email arrives.

The name is missing.
The format is different.
The request is unclear.

And suddenly the automation does the wrong thing confidently.

That is not an AI problem.
That is a workflow problem.

Before you automate anything, decide three things:

What should go through?
What should be reviewed?
And what should stop?

If your AI does not know what to do with weird inputs,
it will break the moment real life shows up.
```

Warum besser:

- kein Jargon
- konkretes Beispiel: weird email
- klares Problem
- klare 3er-Regel
- Anfänger verstehen es sofort
- Fortgeschrittene merken trotzdem: es geht um exception handling

---

## Visuals dazu

Statt abstrakter Systemkarten:

```text
Scene 1:
Perfect demo inbox. Everything green.

Scene 2:
A weird email appears:
“hey can you handle this maybe???”

Scene 3:
AI confidently routes it wrong.

Scene 4:
Split path:
Normal → automate
Unclear → review
Dangerous/weird → stop

Scene 5:
Rule:
Don’t automate the happy path.
Plan for the weird one.
```

Das ist viel zugänglicher.

---

## Empfehlung

Wir pivoten die Reihe von:

```text
Systems over prompts.
```

zu einer zugänglicheren Oberfläche:

```text
Make AI work in real life.
```

Der Brand-Kern bleibt:

```text
Systems over prompts.
```

Aber der Zuschauer-Hook wird alltagstauglicher.

## Konkrete nächste Änderung

Ich würde jetzt als nächsten Slice bauen:

```text
Beginner-friendly voiceover variants
+ unified text source for top field / captions / subtitles
+ regenerate Clip 1 as “weird email” version
```

Also:

```text
FinalVoiceoverScript
→ top card text
→ subtitles
→ audio
→ review MP4
```

Dann ist alles synchron und deutlich weniger abgehoben.

Explanation:
**Deciding on variants**

I need to establish my next point: I'll focus on two variants, "Beginner/Mainstream" and "Builder/Advanced." I think I should recommend the beginner option. I might say that there's no re-render until the copy is fixed, and I could mention a concrete replacement script. But I have to be careful not to say "will" unless I confirm with the tool. Saying "Nächster Schritt ist..." might work, but I shouldn’t make any promises. I’ll keep it concise!


[M G] Ja das ist besser. Mach jetzt weiter.


Explanation:
**Taking action for TDD implementation**

I need to take action and load the Test-Driven Development (TDD) process, making sure it's systematic and relevant. It's essential to use the right tools during this implementation. I also need to inspect the files involved to ensure everything's in order. It feels like there's a lot to accomplish, and I want to make sure I'm covering all necessary steps properly! Let's keep pushing forward on this!


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.


     1|"""Final short-form voiceover scripts for TrueTrace review renders."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|
     7|from autoshorts.ideas.candidate import ValidationResult
     8|
     9|
    10|_GENERIC_FILLER = (
    11|    "in this video",
    12|    "unlock your full potential",
    13|    "game changer",
    14|    "you won't believe",
    15|)
    16|
    17|
    18|@dataclass(frozen=True)
    19|class FinalVoiceoverScript:
    20|    candidate_id: str
    21|    text: str
    22|    language: str
    23|    voice: str
    24|    target_duration_seconds: int
    25|    side_effects: tuple[str, ...] = ()
    26|    external_calls: tuple[str, ...] = ()
    27|
    28|
    29|_SCRIPTS: dict[str, tuple[str, int]] = {
    30|    "truetrace-ai-automation-quiet-rot-38s": (
    31|        "Most AI automations do not fail dramatically. They quietly rot. "
    32|        "Day one looks clean, because the demo input behaves. By day three, the real input changes. "
    33|        "Now the workflow needs an exception path. Normal goes forward. Weird goes to review. Unclear stops. "
    34|        "If your automation has no exception path, it is not a system. It is a lucky prompt.",
    35|        24,
    36|    ),
    37|    "truetrace-prompt-vs-system-workflow-breaks-35s": (
    38|        "A smart prompt can still build a fragile workflow. The prompt is only one part. "
    39|        "A system needs an input boundary, an output standard, a review step, and an action boundary. "
    40|        "Without those, the same prompt behaves differently every week. Do not ask for a better prompt first. "
    41|        "Ask what the workflow must refuse, review, and repeat.",
    42|        23,
    43|    ),
    44|    "truetrace-ai-notes-equal-importance-34s": (
    45|        "AI notes fail when everything looks equally important. A pretty summary is not a decision system. "
    46|        "Separate facts from decisions, risks, open questions, and next actions. "
    47|        "Now the note tells you what changed, what matters, and what needs a human. "
    48|        "Do not ask AI for more notes. Ask it for decision structure.",
    49|        22,
    50|    ),
    51|}
    52|
    53|
    54|def build_truetrace_final_voiceover(candidate_id: str) -> FinalVoiceoverScript:
    55|    if candidate_id not in _SCRIPTS:
    56|        raise ValueError(f"unknown TrueTrace candidate id: {candidate_id}")
    57|    text, duration = _SCRIPTS[candidate_id]
    58|    script = FinalVoiceoverScript(
    59|        candidate_id=candidate_id,
    60|        text=text,
    61|        language="en",
    62|        voice="en-GB-RyanNeural",
    63|        target_duration_seconds=duration,
    64|    )
    65|    validation = validate_final_voiceover_script(script)
    66|    if not validation.is_valid:
    67|        raise ValueError("; ".join(validation.errors))
    68|    return script
    69|
    70|
    71|def validate_final_voiceover_script(script: FinalVoiceoverScript) -> ValidationResult:
    72|    errors: list[str] = []
    73|    if not script.candidate_id.strip():
    74|        errors.append("candidate_id is required")
    75|    if not script.text.strip():
    76|        errors.append("text is required")
    77|    if script.language != "en":
    78|        errors.append("final voiceover language must be en")
    79|    if not script.voice.strip():
    80|        errors.append("voice is required")
    81|    if not (15 <= script.target_duration_seconds <= 45):
    82|        errors.append("target_duration_seconds must be between 15 and 45")
    83|    if any(phrase in script.text.casefold() for phrase in _GENERIC_FILLER):
    84|        errors.append("voiceover contains generic intro/filler language")
    85|    if len(script.text.split()) > 80:
    86|        errors.append("voiceover must stay under 80 words")
    87|    if script.side_effects:
    88|        errors.append("voiceover script must not have side effects")
    89|    if script.external_calls:
    90|        errors.append("voiceover script must not have external calls")
    91|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
    92|


     1|"""Write deterministic local preview PNG scene cards."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|from pathlib import Path
     7|from textwrap import wrap
     8|from typing import Any
     9|
    10|from PIL import Image, ImageDraw, ImageFont
    11|
    12|from autoshorts.rendering.local_preview import LocalPreviewPlan, validate_local_preview_plan
    13|
    14|
    15|@dataclass(frozen=True)
    16|class LocalFrameWriteResult:
    17|    script_id: str
    18|    output_dir: str
    19|    frame_paths: tuple[str, ...]
    20|    side_effects: tuple[str, ...] = ("write_png",)
    21|    external_calls: tuple[str, ...] = ()
    22|
    23|
    24|@dataclass(frozen=True)
    25|class LocalizedFrameCopy:
    26|    scene_label: str
    27|    caption_text: str
    28|    asset_label: str
    29|    motion_text: str
    30|
    31|
    32|_GERMAN_SCENE_LABELS = {
    33|    "first_frame_hook": "AUFMERKSAMKEIT",
    34|    "pattern_interrupt": "MUSTERBRUCH",
    35|    "core_explanation": "KERNIDEE",
    36|    "screen_or_card_visual": "BEISPIEL",
    37|    "cta": "FREIGABE",
    38|}
    39|
    40|_GERMAN_CAPTIONS = {
    41|    "first_frame_hook": "Wenn KI deine Woche steuert, hast du die Kontrolle bereits abgegeben.",
    42|    "pattern_interrupt": "Das Problem ist nicht zu wenig KI, sondern zu wenig Führung.",
    43|    "core_explanation": "Nutze KI als Assistent: Ziele prüfen, Aufgaben ordnen, nächste Schritte festlegen.",
    44|    "screen_or_card_visual": "Ein 10-Minuten-Review zeigt: Was bleibt, was fällt weg, was kommt zuerst?",
    45|    "cta": "Freigabe prüfen: Versteht man die Idee sofort und bleibt die Kontrolle beim Menschen?",
    46|}
    47|
    48|_GERMAN_ASSET_LABELS = {
    49|    "kinetic_text_overlay": "Text-Overlay",
    50|    "mechanism_animation": "Mechanik-Animation",
    51|    "screen_recording_simulation": "Screen/Card-Simulation",
    52|    "cinematic_ai_video": "Cinematic AI-Visual",
    53|}
    54|
    55|_GERMAN_MOTION = {
    56|    "first_frame_hook": "Schneller Einstieg mit grossem Hook-Text; kein Text im Bildmaterial eingebrannt.",
    57|    "pattern_interrupt": "Kurzer visueller Bruch, dann klare Problem-Zuspitzung.",
    58|    "core_explanation": "Schrittweise Bewegung: Ziele, Aufgaben, nächste Aktion erscheinen nacheinander.",
    59|    "screen_or_card_visual": "Langsamer Zoom auf eine Review-Karte mit klaren Abschnitten.",
    60|    "cta": "Ruhiger Abschluss mit klarer Freigabe-Frage und kurzem Call-to-Action.",
    61|}
    62|
    63|
    64|def _validate_output_dir(output_dir: str | Path) -> Path:
    65|    path = Path(output_dir)
    66|    if not str(path).strip() or ".." in path.parts:
    67|        raise ValueError("output_dir must be explicit and normalized")
    68|    return path
    69|
    70|
    71|def _font(size: int):
    72|    try:
    73|        return ImageFont.truetype("DejaVuSans.ttf", size=size)
    74|    except OSError:
    75|        return ImageFont.load_default()
    76|
    77|
    78|def _text_width(text: str, font: Any) -> int:
    79|    scratch = Image.new("RGB", (1, 1))
    80|    draw = ImageDraw.Draw(scratch)
    81|    bbox = draw.textbbox((0, 0), text, font=font)
    82|    return int(bbox[2] - bbox[0])
    83|
    84|
    85|def wrap_text_to_pixel_width(text: str, *, max_width_px: int, font_size: int) -> tuple[str, ...]:
    86|    """Wrap text using measured pixel width instead of a fixed character count."""
    87|
    88|    font = _font(font_size)
    89|    lines: list[str] = []
    90|    for paragraph in text.splitlines() or [text]:
    91|        words = paragraph.split()
    92|        if not words:
    93|            lines.append("")
    94|            continue
    95|        current = words[0]
    96|        for word in words[1:]:
    97|            candidate = f"{current} {word}"
    98|            if _text_width(candidate, font) <= max_width_px:
    99|                current = candidate
   100|            else:
   101|                lines.append(current)
   102|                current = word
   103|        lines.append(current)
   104|    return tuple(lines)
   105|
   106|
   107|def _wrapped(text: str, width: int) -> str:
   108|    lines: list[str] = []
   109|    for paragraph in text.splitlines() or [text]:
   110|        lines.extend(wrap(paragraph, width=width) or [""])
   111|    return "\n".join(lines)
   112|
   113|
   114|def _palette(asset_mode: str) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
   115|    palettes = {
   116|        "kinetic_text_overlay": ((15, 23, 42), (96, 165, 250)),
   117|        "mechanism_animation": ((17, 24, 39), (52, 211, 153)),
   118|        "screen_recording_simulation": ((24, 24, 27), (250, 204, 21)),
   119|        "cinematic_ai_video": ((28, 25, 23), (251, 146, 60)),
   120|    }
   121|    return palettes.get(asset_mode, ((17, 24, 39), (148, 163, 184)))
   122|
   123|
   124|def localize_frame_copy(frame, *, language: str = "de") -> LocalizedFrameCopy:
   125|    """Return approval-preview copy, German by default; final videos can stay English later."""
   126|
   127|    if language != "de":
   128|        return LocalizedFrameCopy(
   129|            scene_label=str(frame.scene_purpose.replace("_", " ").upper()),
   130|            caption_text=str(frame.caption_text),
   131|            asset_label=str(frame.asset_mode),
   132|            motion_text=str(frame.motion_instruction),
   133|        )
   134|    return LocalizedFrameCopy(
   135|        scene_label=str(_GERMAN_SCENE_LABELS.get(frame.scene_purpose, frame.scene_purpose.replace("_", " ").upper())),
   136|        caption_text=str(_GERMAN_CAPTIONS.get(frame.scene_purpose, frame.caption_text)),
   137|        asset_label=str(_GERMAN_ASSET_LABELS.get(frame.asset_mode, frame.asset_mode)),
   138|        motion_text=str(_GERMAN_MOTION.get(frame.scene_purpose, frame.motion_instruction)),
   139|    )
   140|
   141|
   142|def _draw_lines(draw: ImageDraw.ImageDraw, xy: tuple[int, int], lines: tuple[str, ...], *, fill, font, spacing: int) -> None:
   143|    x, y = xy
   144|    line_height = font.size + spacing if hasattr(font, "size") else 22 + spacing
   145|    for line in lines:
   146|        draw.text((x, y), line, fill=fill, font=font)
   147|        y += line_height
   148|
   149|
   150|def _draw_card(plan: LocalPreviewPlan, frame, *, approval_language: str = "de") -> Image.Image:
   151|    background, accent = _palette(frame.asset_mode)
   152|    image = Image.new("RGB", (plan.width, plan.height), background)
   153|    draw = ImageDraw.Draw(image)
   154|    copy = localize_frame_copy(frame, language=approval_language)
   155|
   156|    margin = max(28, plan.width // 16)
   157|    content_width = plan.width - (2 * margin)
   158|    title_font_size = max(28, plan.width // 16)
   159|    body_font_size = max(16, plan.width // 30)
   160|    small_font_size = max(14, plan.width // 34)
   161|    title_font = _font(title_font_size)
   162|    body_font = _font(body_font_size)
   163|    small_font = _font(small_font_size)
   164|
   165|    draw.rectangle((0, 0, plan.width, 18), fill=accent)
   166|    draw.text((margin, margin), copy.scene_label, fill=accent, font=small_font)
   167|
   168|    caption_lines = wrap_text_to_pixel_width(copy.caption_text, max_width_px=content_width, font_size=title_font_size)
   169|    _draw_lines(draw, (margin, margin + 46), caption_lines, fill=(248, 250, 252), font=title_font, spacing=10)
   170|
   171|    info_top = plan.height // 2
   172|    draw.rounded_rectangle(
   173|        (margin, info_top, plan.width - margin, plan.height - margin),
   174|        radius=18,
   175|        outline=accent,
   176|        width=2,
   177|        fill=(background[0] + 8, background[1] + 8, background[2] + 8),
   178|    )
   179|    body = (
   180|        f"Zeit: {frame.start_ms / 1000:.0f}s + {frame.duration_ms / 1000:.0f}s\n"
   181|        f"Asset: {copy.asset_label}\n"
   182|        f"Label: {copy.scene_label.title()}\n\n"
   183|        f"Bewegung: {copy.motion_text}"
   184|    )
   185|    body_lines = tuple(
   186|        line
   187|        for paragraph in body.splitlines()
   188|        for line in (wrap_text_to_pixel_width(paragraph, max_width_px=content_width - 36, font_size=body_font_size) or ("",))
   189|    )
   190|    _draw_lines(draw, (margin + 18, info_top + 18), body_lines, fill=(226, 232, 240), font=body_font, spacing=6)
   191|    return image
   192|
   193|
   194|def write_local_preview_frames(
   195|    plan: LocalPreviewPlan,
   196|    *,
   197|    output_dir: str | Path | None = None,
   198|    approval_language: str = "de",
   199|) -> LocalFrameWriteResult:
   200|    """Write deterministic PNG scene cards for a validated local preview plan."""
   201|
   202|    validation = validate_local_preview_plan(plan)
   203|    if not validation.is_valid:
   204|        raise ValueError("; ".join(validation.errors))
   205|
   206|    target_dir = _validate_output_dir(output_dir or plan.output_dir)
   207|    target_dir.mkdir(parents=True, exist_ok=True)
   208|
   209|    frame_paths: list[str] = []
   210|    for frame in plan.frames:
   211|        path = target_dir / f"{frame.frame_id}.png"
   212|        image = _draw_card(plan, frame, approval_language=approval_language)
   213|        image.save(path, format="PNG")
   214|        frame_paths.append(str(path))
   215|
   216|    return LocalFrameWriteResult(
   217|        script_id=plan.script_id,
   218|        output_dir=str(target_dir),
   219|        frame_paths=tuple(frame_paths),
   220|        side_effects=("write_png",),
   221|        external_calls=(),
   222|    )
   223|


     1|"""Retention-led shot planning before any video rendering."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|from typing import Literal
     7|
     8|from autoshorts.content.script_draft import ScriptDraft, validate_script_draft
     9|from autoshorts.ideas.candidate import ValidationResult
    10|
    11|ShotPurpose = Literal[
    12|    "first_frame_hook",
    13|    "pattern_interrupt",
    14|    "core_explanation",
    15|    "screen_or_card_visual",
    16|    "cta",
    17|]
    18|AssetMode = Literal[
    19|    "kinetic_text_overlay",
    20|    "mechanism_animation",
    21|    "screen_recording_simulation",
    22|    "cinematic_ai_video",
    23|    "static_slideshow",
    24|]
    25|
    26|_ALLOWED_ASSET_MODES = {
    27|    "kinetic_text_overlay",
    28|    "mechanism_animation",
    29|    "screen_recording_simulation",
    30|    "cinematic_ai_video",
    31|}
    32|_BAKED_TEXT_MARKERS = ("baked-in", "fake ui text", "generated text", "unreadable ui")
    33|
    34|
    35|@dataclass(frozen=True)
    36|class ShotScene:
    37|    purpose: ShotPurpose | str
    38|    start_seconds: int
    39|    end_seconds: int
    40|    voiceover_beat: str
    41|    visual_action: str
    42|    asset_mode: AssetMode | str
    43|    caption_beat: str
    44|    retention_goal: str
    45|    labels_renderer_owned: bool = True
    46|
    47|
    48|@dataclass(frozen=True)
    49|class ShotPlan:
    50|    script_id: str
    51|    title: str
    52|    estimated_duration_seconds: int
    53|    scenes: tuple[ShotScene, ...]
    54|
    55|
    56|def _is_blank(value: str) -> bool:
    57|    return not (value or "").strip()
    58|
    59|
    60|def _scene_windows(duration_seconds: int) -> tuple[tuple[int, int], ...]:
    61|    first_end = min(3, duration_seconds)
    62|    second_end = min(8, duration_seconds)
    63|    third_end = min(17, duration_seconds)
    64|    fourth_end = min(max(duration_seconds - 5, third_end + 1), duration_seconds)
    65|    return (
    66|        (0, first_end),
    67|        (first_end, second_end),
    68|        (second_end, third_end),
    69|        (third_end, fourth_end),
    70|        (fourth_end, duration_seconds),
    71|    )
    72|
    73|
    74|def create_shot_plan(script: ScriptDraft) -> ShotPlan:
    75|    """Create a deterministic five-scene retention shot plan from a script draft."""
    76|
    77|    validation = validate_script_draft(script)
    78|    if not validation.is_valid:
    79|        raise ValueError("; ".join(validation.errors))
    80|
    81|    windows = _scene_windows(script.estimated_duration_seconds)
    82|    beats = script.retention_beats
    83|    scenes = (
    84|        ShotScene(
    85|            purpose="first_frame_hook",
    86|            start_seconds=windows[0][0],
    87|            end_seconds=windows[0][1],
    88|            voiceover_beat=script.hook,
    89|            visual_action="Immediate motion punch-in on clean kinetic hook text; no generated UI lettering.",
    90|            asset_mode="kinetic_text_overlay",
    91|            caption_beat=script.hook,
    92|            retention_goal="Stop the scroll with a first-frame hook and pattern interrupt.",
    93|        ),
    94|        ShotScene(
    95|            purpose="pattern_interrupt",
    96|            start_seconds=windows[1][0],
    97|            end_seconds=windows[1][1],
    98|            voiceover_beat=beats[0],
    99|            visual_action="Rapid desk-to-phone change showing messy priorities becoming a controlled workflow.",
   100|            asset_mode="mechanism_animation",
   101|            caption_beat="AI suggests. Human approves.",
   102|            retention_goal="Create visible change before the viewer can classify it as a slideshow.",
   103|        ),
   104|        ShotScene(
   105|            purpose="core_explanation",
   106|            start_seconds=windows[2][0],
   107|            end_seconds=windows[2][1],
   108|            voiceover_beat=beats[1] if len(beats) > 1 else script.script_outline,
   109|            visual_action="Simulated screen flow with renderer-owned boxes, cursor motion, and approval checkpoint.",
   110|            asset_mode="screen_recording_simulation",
   111|            caption_beat="The rule: AI drafts, you decide.",
   112|            retention_goal="Make the mechanism concrete without trusting generated text inside assets.",
   113|        ),
   114|        ShotScene(
   115|            purpose="screen_or_card_visual",
   116|            start_seconds=windows[3][0],
   117|            end_seconds=windows[3][1],
   118|            voiceover_beat=beats[2] if len(beats) > 2 else script.script_outline,
   119|            visual_action="Animated checklist/card sequence showing the weekly review routine step by step.",
   120|            asset_mode="mechanism_animation",
   121|            caption_beat="10-minute review. One human checkpoint.",
   122|            retention_goal="Deliver the payoff visually before the CTA.",
   123|        ),
   124|        ShotScene(
   125|            purpose="cta",
   126|            start_seconds=windows[4][0],
   127|            end_seconds=windows[4][1],
   128|            voiceover_beat=script.cta,
   129|            visual_action="Clean kinetic CTA overlay with renderer-owned caption and final checklist card motion.",
   130|            asset_mode="kinetic_text_overlay",
   131|            caption_beat=script.cta,
   132|            retention_goal="Give a concrete comment action tied to the promised checklist.",
   133|        ),
   134|    )
   135|    return ShotPlan(
   136|        script_id=script.brief_id,
   137|        title=script.title,
   138|        estimated_duration_seconds=script.estimated_duration_seconds,
   139|        scenes=scenes,
   140|    )
   141|
   142|
   143|def validate_shot_plan(plan: ShotPlan) -> ValidationResult:
   144|    errors: list[str] = []
   145|    if _is_blank(plan.script_id):
   146|        errors.append("script_id is required")
   147|    if _is_blank(plan.title):
   148|        errors.append("title is required")
   149|    if not 10 <= plan.estimated_duration_seconds <= 180:
   150|        errors.append("duration_seconds must be between 10 and 180")
   151|    if not plan.scenes:
   152|        errors.append("at least one scene is required")
   153|    elif plan.scenes[0].purpose != "first_frame_hook":
   154|        errors.append("first scene must be first_frame_hook")
   155|
   156|    previous_end: int | None = None
   157|    for index, scene in enumerate(plan.scenes, start=1):
   158|        if scene.end_seconds <= scene.start_seconds:
   159|            errors.append(f"scene {index} end_seconds must be greater than start_seconds")
   160|        if previous_end is not None and scene.start_seconds < previous_end:
   161|            errors.append(f"scene {index} start_seconds overlaps previous scene")
   162|        previous_end = scene.end_seconds
   163|
   164|        for field_name, value in {
   165|            "purpose": scene.purpose,
   166|            "voiceover_beat": scene.voiceover_beat,
   167|            "visual_action": scene.visual_action,
   168|            "asset_mode": scene.asset_mode,
   169|            "caption_beat": scene.caption_beat,
   170|            "retention_goal": scene.retention_goal,
   171|        }.items():
   172|            if _is_blank(value):
   173|                errors.append(f"scene {index} {field_name} is required")
   174|
   175|        if scene.asset_mode == "static_slideshow":
   176|            errors.append("static slideshow asset mode is not allowed")
   177|        elif scene.asset_mode not in _ALLOWED_ASSET_MODES:
   178|            errors.append(f"unsupported asset mode: {scene.asset_mode}")
   179|        if not scene.labels_renderer_owned:
   180|            errors.append("labels and captions must be renderer-owned")
   181|        visual = scene.visual_action.casefold()
   182|        if any(marker in visual for marker in _BAKED_TEXT_MARKERS):
   183|            errors.append("baked-in fake UI text is not allowed")
   184|
   185|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   186|


     1|from autoshorts.audio.voiceover import (
     2|    FinalVoiceoverScript,
     3|    build_truetrace_final_voiceover,
     4|    validate_final_voiceover_script,
     5|)
     6|
     7|
     8|def test_build_truetrace_final_voiceover_for_quiet_rot_is_concise_and_retention_led():
     9|    script = build_truetrace_final_voiceover("truetrace-ai-automation-quiet-rot-38s")
    10|
    11|    assert script.candidate_id == "truetrace-ai-automation-quiet-rot-38s"
    12|    assert script.language == "en"
    13|    assert script.voice == "en-GB-RyanNeural"
    14|    assert script.target_duration_seconds == 24
    15|    assert len(script.text.split()) <= 70
    16|    assert script.text.startswith("Most AI automations do not fail dramatically.")
    17|    assert "exception path" in script.text
    18|    assert "Follow" not in script.text
    19|    assert script.side_effects == ()
    20|    assert script.external_calls == ()
    21|
    22|
    23|def test_build_truetrace_final_voiceover_has_all_top3_scripts_with_stable_duration():
    24|    ids = (
    25|        "truetrace-ai-automation-quiet-rot-38s",
    26|        "truetrace-prompt-vs-system-workflow-breaks-35s",
    27|        "truetrace-ai-notes-equal-importance-34s",
    28|    )
    29|
    30|    scripts = tuple(build_truetrace_final_voiceover(candidate_id) for candidate_id in ids)
    31|
    32|    assert [script.target_duration_seconds for script in scripts] == [24, 23, 22]
    33|    assert all(45 <= len(script.text.split()) <= 70 for script in scripts)
    34|    assert all(script.text.count(".") >= 3 for script in scripts)
    35|
    36|
    37|def test_validate_final_voiceover_script_rejects_bad_language_duration_and_filler():
    38|    script = FinalVoiceoverScript(
    39|        candidate_id="candidate",
    40|        text="In this video we will unlock your full potential with a very long and generic intro.",
    41|        language="de",
    42|        voice="en-GB-RyanNeural",
    43|        target_duration_seconds=5,
    44|    )
    45|
    46|    validation = validate_final_voiceover_script(script)
    47|
    48|    assert validation.is_valid is False
    49|    assert "final voiceover language must be en" in validation.errors
    50|    assert "target_duration_seconds must be between 15 and 45" in validation.errors
    51|    assert "voiceover contains generic intro/filler language" in validation.errors
    52|


patch

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

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


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_voiceover.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_voiceover.py
@@ -1,5 +1,6 @@
 from autoshorts.audio.voiceover import (
     FinalVoiceoverScript,
+    build_accessible_script_draft_from_voiceover,
     build_truetrace_final_voiceover,
     validate_final_voiceover_script,
 )

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_voiceover.py">
ERROR [3:5] "build_accessible_script_draft_from_voiceover" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>