Explanation:
**Considering patch workflow**

I need to think about perhaps patching code for the future. Should I add a postprocess function or search tests? Hmm, maybe that's not needed. The developer suggests I keep working on it, which makes me wonder if I should focus on improving the patch workflow to use 432x768 instead. The comparison between 416x736 and a resolution of 9/16 also has me thinking about whether I need to add a script or check where the resolution is set.


Explanation:
**Considering file resolution issues**

I'm pondering why the read file has resolution while the search didn’t. Maybe there’s a .gitignore at play? I need to think about adding post-processing to the i2v runner once the client copies the output. It’s probably worth inspecting the ComfyUI client to understand better. I’m just trying to piece together all these details! Let’s see what comes next.


     1|"""Small ComfyUI REST client used by AutoShortsBot clip jobs."""
     2|
     3|from __future__ import annotations
     4|
     5|import json
     6|import time
     7|import urllib.parse
     8|import urllib.request
     9|from pathlib import Path
    10|from typing import Any
    11|
    12|
    13|class UrllibComfyUITransport:
    14|    """HTTP transport for local/Tailscale ComfyUI instances."""
    15|
    16|    def upload_image(self, base_url: str, path: Path, *, remote_name: str, overwrite: bool = True) -> dict[str, Any]:
    17|        boundary = "----AutoShortsBotBoundary"
    18|        body: list[bytes] = []
    19|
    20|        def add(part: str | bytes) -> None:
    21|            body.append(part.encode() if isinstance(part, str) else part)
    22|
    23|        add(
    24|            f'--{boundary}\r\nContent-Disposition: form-data; name="image"; filename="{remote_name}"\r\n'
    25|            "Content-Type: image/png\r\n\r\n"
    26|        )
    27|        add(path.read_bytes())
    28|        add("\r\n")
    29|        add(f'--{boundary}\r\nContent-Disposition: form-data; name="type"\r\n\r\ninput\r\n')
    30|        add(f'--{boundary}\r\nContent-Disposition: form-data; name="overwrite"\r\n\r\n{str(overwrite).lower()}\r\n')
    31|        add(f"--{boundary}--\r\n")
    32|        request = urllib.request.Request(
    33|            f"{base_url}/upload/image",
    34|            data=b"".join(body),
    35|            headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
    36|        )
    37|        return json.loads(urllib.request.urlopen(request, timeout=120).read().decode())
    38|
    39|    def post_json(self, base_url: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
    40|        request = urllib.request.Request(
    41|            f"{base_url}{endpoint}",
    42|            data=json.dumps(payload).encode(),
    43|            headers={"Content-Type": "application/json"},
    44|        )
    45|        raw = urllib.request.urlopen(request, timeout=120).read().decode()
    46|        return json.loads(raw) if raw else {}
    47|
    48|    def wait_for_completion(
    49|        self,
    50|        base_url: str,
    51|        prompt_id: str,
    52|        *,
    53|        poll_seconds: float,
    54|        timeout_seconds: int,
    55|    ) -> dict[str, Any]:
    56|        deadline = time.monotonic() + timeout_seconds
    57|        while time.monotonic() < deadline:
    58|            queue = json.loads(urllib.request.urlopen(f"{base_url}/queue", timeout=30).read().decode())
    59|            running = queue.get("queue_running", [])
    60|            pending = queue.get("queue_pending", [])
    61|            if not running and not pending:
    62|                history = json.loads(urllib.request.urlopen(f"{base_url}/history/{prompt_id}", timeout=60).read().decode())
    63|                return history.get(prompt_id, {})
    64|            time.sleep(poll_seconds)
    65|        raise TimeoutError(f"ComfyUI prompt did not finish within {timeout_seconds}s: {prompt_id}")
    66|
    67|    def download_view(self, base_url: str, output: dict[str, Any], destination: Path) -> Path:
    68|        params = urllib.parse.urlencode(
    69|            {
    70|                "filename": output["filename"],
    71|                "subfolder": output.get("subfolder", ""),
    72|                "type": output.get("type", "output"),
    73|            }
    74|        )
    75|        destination.parent.mkdir(parents=True, exist_ok=True)
    76|        destination.write_bytes(urllib.request.urlopen(f"{base_url}/view?{params}", timeout=300).read())
    77|        return destination
    78|
    79|
    80|class ComfyUIClient:
    81|    """Minimal client for uploading keyframes, running workflows, and downloading MP4 outputs."""
    82|
    83|    def __init__(self, base_url: str, *, transport: Any | None = None):
    84|        self.base_url = base_url.rstrip("/")
    85|        self.transport = transport or UrllibComfyUITransport()
    86|
    87|    def run_i2v_workflow(
    88|        self,
    89|        *,
    90|        workflow: dict[str, Any],
    91|        keyframe_path: Path,
    92|        remote_image_name: str,
    93|        output_path: Path,
    94|        poll_seconds: float = 5,
    95|        timeout_seconds: int = 900,
    96|    ) -> dict[str, Any]:
    97|        upload = self.transport.upload_image(self.base_url, keyframe_path, remote_name=remote_image_name, overwrite=True)
    98|        prompt_response = self.transport.post_json(
    99|            self.base_url,
   100|            "/prompt",
   101|            {"prompt": workflow, "client_id": "autoshorts-i2v"},
   102|        )
   103|        prompt_id = prompt_response["prompt_id"]
   104|        history = self.transport.wait_for_completion(
   105|            self.base_url,
   106|            prompt_id,
   107|            poll_seconds=poll_seconds,
   108|            timeout_seconds=timeout_seconds,
   109|        )
   110|        status = history.get("status", {})
   111|        if status.get("status_str") != "success":
   112|            return {"status": "failed", "prompt_id": prompt_id, "history": history, "upload": upload}
   113|        output = _first_video_output(history)
   114|        self.transport.download_view(self.base_url, output, output_path)
   115|        return {
   116|            "status": "success",
   117|            "prompt_id": prompt_id,
   118|            "output_path": str(output_path),
   119|            "remote_output": output,
   120|            "upload": upload,
   121|        }
   122|
   123|
   124|def _first_video_output(history: dict[str, Any]) -> dict[str, Any]:
   125|    for node_output in history.get("outputs", {}).values():
   126|        for item in node_output.get("images", []):
   127|            if str(item.get("filename", "")).lower().endswith(".mp4"):
   128|                return item
   129|    raise ValueError("No MP4 output found in ComfyUI history")
   130|


Explanation:
**Normalizing video outputs**

I need to think about whether a patch is necessary to normalize after the download, which might require ffmpeg. Maybe it's best to keep the final assembly separate from ComfyUI. The user has expressed some complaints, so I'm considering creating a `normalize_vertical_mp4` function. However, I want to avoid overcomplicating things. It might be a good idea to add a helper script to fix the vertical aspect and ensure the user gets the corrected output they want.

#!/usr/bin/env python3
"""Normalize MP4s for mobile 9:16 playback.

This is intentionally a post-render boundary: it does not alter source clips;
it writes a square-pixel 1080x1920 H.264 export with explicit SAR/DAR metadata.
"""

from __future__ import annotations

import argparse
import json
import subprocess
from pathlib import Path

TARGET_WIDTH = 1080
TARGET_HEIGHT = 1920

def normalize_vertical_mp4(input_path: Path, output_path: Path) -> dict[str, object]:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    video_filter = (
        f"scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=increase,"
        f"crop={TARGET_WIDTH}:{TARGET_HEIGHT},"
        "setsar=1,setdar=9/16,format=yuv420p"
    )
    cmd = [
        "ffmpeg",
        "-y",
        "-hide_banner",
        "-loglevel",
        "error",
        "-i",
        str(input_path),
        "-vf",
        video_filter,
        "-c:v",
        "libx264",
        "-preset",
        "veryfast",
        "-crf",
        "18",
        "-movflags",
        "+faststart",
        "-an",
        str(output_path),
    ]
    subprocess.check_call(cmd)
    probe = json.loads(
        subprocess.check_output(
            [
                "ffprobe",
                "-v",
                "error",
                "-select_streams",
                "v:0",
                "-show_entries",
                "stream=width,height,sample_aspect_ratio,display_aspect_ratio",
                "-show_entries",
                "format=duration",
                "-of",
                "json",
                str(output_path),
            ],
            text=True,
        )
    )
    stream = probe["streams"][0]
    return {
        "output_path": str(output_path),
        "width": stream["width"],
        "height": stream["height"],
        "sample_aspect_ratio": stream.get("sample_aspect_ratio"),
        "display_aspect_ratio": stream.get("display_aspect_ratio"),
        "duration": float(probe.get("format", {}).get("duration", 0.0)),
    }

def main() -> None:
    parser = argparse.ArgumentParser(description="Normalize MP4 to mobile-safe 9:16 1080x1920.")
    parser.add_argument("input", type=Path)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    print(json.dumps(normalize_vertical_mp4(args.input, args.output), indent=2))

if __name__ == "__main__":
    main()


terminal

Output:
{
  "output_path": "/tmp/autoshorts_aspect_check/scene_01_seed_101_fixed_by_script.mp4",
  "width": 1080,
  "height": 1920,
  "sample_aspect_ratio": "1:1",
  "display_aspect_ratio": "9:16",
  "duration": 5.083333
}


ffmpeg -y -hide_banner -loglevel error -i /tmp/autoshorts_aspect_check/scene_01_seed_101_fixed_by_script.mp4 -vf "select=eq(n\,10)" -frames:v 1 /tmp/autoshorts_aspect_check/scene_01_fixed_frame.png
python - <<'PY'
from PIL import Image
p='/tmp/autoshorts_aspect_check/scene_01_fixed_frame.png'
im=Image.open(p)
print(im.size, im.getbbox())
PY


Output: (1080, 1920) (0, 0, 1080, 1920)


Stimmt, Sir — das ist ein Export-/Playback-Problem, nicht nur ein Schönheitsfehler.

Ich habe den Clip jetzt **mobile-safe neu normalisiert**:

- Output: **1080×1920**
- SAR: **1:1**
- DAR: **9:16**
- Filter: `scale → crop → setsar=1 → setdar=9/16`
- Keine Letterbox-/Pillarbox-Metadaten-Spielchen mehr. Telegram sollte das jetzt korrekt hochkant anzeigen.

Ich habe außerdem ein kleines Fix-Script angelegt für künftige Exporte:

`/home/agent/jarvis_runtime/AutoShortsBot/scripts/fix_vertical_aspect.py`

Korrigierte Proof-Clips:

**Scene 01 – fixed 9:16**
MEDIA:/home/agent/jarvis_runtime/AutoShortsBot/data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_01_seed_101_1080x1920_sar1.mp4

**Scene 02 – fixed 9:16**
MEDIA:/home/agent/jarvis_runtime/AutoShortsBot/data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_02_seed_101_1080x1920_sar1.mp4


[M G] Ok, bereite das nächste video für den nächsten Post vor.


Explanation:
**Preparing for video automation**

I definitely need to act and consider using AutoShorts. I’m also thinking about loading relevant skills I might need beforehand. Maybe I had the social skill loaded from earlier, but each response needs to ensure that skill is mandatory before replying. I should look into social media shorts automation, perhaps GitHub for resources. I need to prepare for the next video and inspect the repository to understand the upcoming post. Let’s figure out the next steps!


social-media-shorts-automation

# Final Motion Hybrid Rendering

Use this pattern for mechanism-reveal Shorts where clarity matters more than cinematic generation.

## Core rule

AI image/video provides only text-free atmosphere and motion texture. The deterministic renderer owns all truth-bearing layers:

- captions and subtitles;
- UI/email/card text;
- arrows, labels, numbers, and diagrams;
- factual claims and final assembly.

Do not ask an image/video model to spell workflow labels or explain the mechanism. It will eventually produce fake UI text, malformed words, or pretty but unclear motion.

## Recommended pipeline

```text
Final voiceover script
→ final video plan / scene timeline
→ scene-specific text-free image prompts
→ procedural motion renderer overlays
→ TTS voiceover
→ compact phrase-boundary subtitles
→ FFmpeg final assembly
→ visual QA frames
```

## Scene-specific ChatGPT Image/Codex backgrounds

When the user points out that ChatGPT Image is available through Codex, prefer it for fast text-free keyframe/background texture before reaching for local Qwen Image/ComfyUI. Local Qwen/ComfyUI remains useful for private/local batches or cost control, but for this class of mechanism-reveal prototype the lowest-friction path is often:

```text
ChatGPT Image/Codex background(s)
→ deterministic renderer overlays
→ FFmpeg assembly
```

For each production scene, create one background prompt that explicitly says:

```text
Vertical 9:16 cinematic background
No readable text
No logos
No watermarks
No UI labels
No fake letters
Leave central negative space for renderer-owned overlays
```

Use scene-specific mood, not baked-in explanations:

- clean demo: calm laptop/workspace/dashboard glow;
- weird input: slightly messy email-workflow atmosphere, amber warning mood;
- wrong routing: abstract AI path energy, subtle red warning tension;
- mechanism split: minimal dark technical background with space for diagram overlay;
- final rule: calm premium blue/green tech background.

Store generated media outside Git; commit only plans, prompts, tests, and docs.

## Renderer-owned mechanism overlays

A good beginner-friendly AI-systems Short should make the mechanism visible. Example pattern:

```text
Clean demo
→ weird email appears
→ AI confidently routes it wrong
→ three-way split:
   Normal → Go through
   Unclear → Review
   Weird/Risky → Stop
→ final rule
```

Render labels, arrows, cards, and captions locally. Check that arrows sit behind labels and that subtitles do not obscure the diagram.

## Subtitle polish for final-motion videos

Use compact phrase-boundary subtitles for final-near renders:

- 1–4 word cues;
- split on semantic punctuation where possible;
- smaller than preview captions;
- placed high enough above bottom UI/safe zones;
- avoid cross-clause chunks like `ANYTHING DECIDE THREE`.

For a 1080x1920 ASS burn-in baseline, a useful starting point is:

```text
Fontsize: 72
MarginV: 320
Max lines: 2
```

Always inspect at least one critical mechanism frame after subtitle burn-in before exporting or sending.

## Approval package after first-public export

Once a 1080x1920 candidate exists, do not stop at "video rendered". Prepare a side-effect-free final approval package before any delivery/platform step:

```text
media path
SHA256 checksum
platform labels
title variants
caption + hashtags
hook
final/review language metadata
quality notes
approval commands
```

Guardrails:

- `requires_human_approval=True`
- `side_effects=()`
- no Telegram/platform calls inside the package builder
- no implicit action words like `publish` or `post`
- verify the checksum and ffprobe metadata from the actual file before presenting approval metadata

This turns a rendered MP4 into an auditable review artifact instead of a loose file path.

## QA gates

Before treating a render as a first-public candidate, verify:

- first 2 seconds communicate the concrete problem;
- top text, subtitles, and voiceover come from one source of truth;
- no generated image contains readable fake text/logos;
- mechanism labels are readable on mobile;
- subtitles do not compete with the core diagram;
- audio and video durations match closely;
- generated media remains ignored by Git.


# TrueTraceShorts strategic refactor notes

Use this reference when developing or revising the user's AutoShortsBot / TrueTraceShorts strategy, briefs, scoring, review packages, or implementation roadmap.

## Core strategic shift

TrueTraceShorts should not be positioned as another AI/prompt-tips channel. The stronger positioning is:

> Trace the hidden system. Fix the outcome.

German explanation:

> Wir zeigen, warum digitale Dinge im Alltag schiefgehen — und welche kleine Systemregel sie repariert.

Keep `Systems over prompts.` as a usable tagline, but interpret it broadly: not just better prompts, but better digital thinking, work, attention, decision, safety, and workflow systems.

The bot is a learning editorial system, not a content automaton:

```text
Research signal → hypothesis → ContentBrief → ScriptDraft → VideoCandidate
→ score/gate → review/preview → human approval → manual post pack
→ analytics feedback → next experiment
```

## Content mix target

For the next strategic test batch, avoid an all-AI feed. Suggested mix for ~60 videos:

- 35% — AI That Actually Helps / AI Output Autopsy
- 20% — Digital Self-Defense: scams, phishing, fake reviews, privacy/data traps, hallucination risks
- 20% — Attention & Decision Systems: focus, to-dos, meetings, decisions, overload
- 15% — Workflow Teardowns: email, calendar, notes, automations, broken work systems
- 10% — Algorithm & Social Search Literacy: feeds, search, recommendations, content consumption

AI remains important, but should usually be 30–40% of the content universe, not 100%.

## Recommended series

- `AI Output Autopsy` — bad AI answer → why it sounds right but fails → rule.
- `Digital Red Flags` — normal-looking digital situation → one warning signal exposes the trap.
- `One Broken Workflow` / `Workflow Teardown` — mini-workflow fails → missing boundary/decision/exception path.
- `Decision Design` — people ask tools for answers when they need decision criteria.
- `Attention Traps` — normal digital behavior explained as an attention mechanism; avoid medical claims.
- `Search Smarter` — TikTok/YouTube/Google/AI search done badly vs. better.
- `Worklife Systems` — meetings, email, notes, calendar, to-dos.

## Standard video structure

Use `visible fail → identification → mechanism → fix → before/after → memorable takeaway`:

1. 0–1.5s: visible conflict in the first frame, not just text.
2. 1.5–5s: viewer recognition / identity.
3. 5–14s: mechanism reveal — why it happens.
4. 14–28s: practical fix — rule, phrase, template, checklist, or decision frame.
5. 28–38s: before/after.
6. final 2–4s: memorable sentence with save/share/follow reason.

Every video must answer:

- What concretely goes wrong?
- Why does it happen?
- What is the simple correction?
- What sentence should stick?
- Why would the viewer save or share it?
- Why would they follow this channel/series?

## ContentBrief fields to add or require

Add these fields before scripting so JARVIS cannot generate generic AI-topic sludge:

- `audience_need`: boredom | urgency | fear | competence | clarity | status | safety | curiosity
- `job_to_be_done`: “When I …, I want to …, so I can …”
- `viewer_identity`: beginner | creator | solo_founder | office_worker | student | parent | small_team | freelancer
- `viewer_emotion_before`: confused | annoyed | overwhelmed | skeptical | curious | embarrassed | anxious
- `viewer_emotion_after`: relieved | smarter | in_control | warned | motivated | equipped
- `share_reason`: “I would send this to someone because …”
- `save_reason`: “I would save this because …”
- `follow_reason`: “I would follow this channel because …”
- `search_intent_phrase`: platform-native problem/search phrase
- `series_id`: ai_output_autopsy | digital_red_flags | decision_design | workflow_teardown | attention_traps | search_smarter | worklife_systems
- `saturation_risk`: low | medium | high
- `human_texture`: real example | mini case | tested prompt | screen recording | personal observation | source-based claim
- `claim_risk`: low | medium | high
- `ai_disclosure_required`: yes | no | unclear

## 100-point scoring model

Score candidates on viewer behavior, not internal cleverness:

- Problem Urgency — 15
- First-Frame Clarity — 12
- Hook Curiosity — 12
- Mechanism Value — 12
- Practical Payoff — 15
- Retention Path — 10
- Share/Save Potential — 10
- Trust/Human Texture — 8
- Series Fit — 4
- Saturation Defense — 2

Hard-block if:

- no concrete problem
- no clear solution
- first frame is only text / no visible conflict
- hook sounds like a generic AI tip
- claim is not sourceable
- too similar to the last ~10 videos
- no save/share/follow reason
- realistic AI-generated content has unclear disclosure status

## Research, claims, and compliance layers

Insert a research layer before scripting:

```text
Signal → hypothesis → brief → script → candidate → score → render
```

Useful planned modules:

- `autoshorts.research.trend_signals`
- `autoshorts.research.social_search`
- `autoshorts.research.topic_saturation`
- `autoshorts.claims.fact_gate`
- `autoshorts.strategy.experiment`
- `autoshorts.compliance.ai_disclosure`

Every factual claim should carry source/risk. Any `unclear` disclosure should block or require manual review before publishing.

## Visual grammar

Motion must explain meaning, not just interrupt attention. Add/maintain a visual grammar keyed by mechanism type:

- missing boundary → open container / leaking information
- decision gap → three paths, one lights up
- false confidence → foggy cards becoming clear with constraints
- urgency trap → timer + red urgency line
- overload → stack of cards sorted by one rule
- before/after → split-screen with marked difference
- exception path → workflow line breaks, detour appears
- attention capture → cursor/thought pulled by app tiles
- hidden cost → free/cheap path reveals later cost

Potential module: `autoshorts.rendering.visual_grammar`.

## Captions, voice, and human texture

- Forced alignment is a top priority. If captions drift, simplify visuals before tolerating bad sync.
- Support three caption modes:
  - `word_by_word` — fast hooks / high-energy TikTok style
  - `phrase_by_phrase` — clearer for complex explanations
  - `keyword_pulse` — calmer premium look for key ideas
- Avoid the vibe: “an AI voice explaining an AI tip about AI.”
- Require human texture: real/credible mini-situation, tested prompt, screen recording, personal observation, or source-based claim.

## Review package v2

Future Telegram/content review packages should include strategy and risk, not only visuals:

- Candidate ID, version, media hash, script hash
- Series, audience need, viewer emotion before/after
- Hook, first-frame description, open loop, mechanism, takeaway
- Save/share/follow reason
- Platform fit per YouTube/TikTok/Instagram
- Claims: source + risk
- AI disclosure: yes/no/unclear per platform
- Policy/brand/saturation/repetition risks
- Experiment hypothesis and expected winning metric

Approval should bind to exact version/hash.

## Analytics early

Do not wait for auto-publishing to add analytics. In phase 1, manually record:

- platform, video_id, post_time, series_id, hook_type, topic_cluster, duration
- first_frame_type, caption_style, voice_style, visual_template, CTA type
- at 1h/24h/72h/7d: views, likes, comments, shares, saves, follows
- YouTube: shown in feed, viewed vs swiped away/chose to view, engaged views, average view duration, average percentage viewed

Diagnosis rules:

- High view-vs-swipe, low completion → hook strong, body weak; get to mechanism faster.
- Low view-vs-swipe, high completion → content good, first frame weak; retest hook/visual.
- High completion, low follows → useful but weak series promise; strengthen CTA/series branding.
- Many saves, few shares → practical but not social; add “send this to …” context.
- Many shares, few saves → relatable but not useful enough; add concrete takeaway.
- Many views, no follows → too generic or not brand-defining.

## 30-day test plan

Prefer 30 controlled experiments over volume:

- 5 series × 6 videos
- test 2 hook types per series
- test 2 length bands: 25–35s and 40–55s
- generate 3 candidates/day but publish only 1/day manually
- compare follow/view, saves/view, shares/view, completion, real problem-resonance comments, and repeatability

Winning format = repeatable reach + trust + follow reason, not just most views.


todo

Repo-Zustand und nächste verfügbare Video-Kandidaten prüfen

Nächsten Post-Kandidaten auswählen und Produktionsartefakte vorbereiten

9:16/Audio/Subtitles/QA verifizieren und Review-Package liefern


terminal

git status --short && python - <<'PY'
import pathlib,json
root=pathlib.Path('data')
for pat in ['**/review*.json','**/*review*','**/*approval*','**/*.mp4','**/*package*']:
 print('\nPAT',pat)
 for p in sorted(root.glob(pat), key=lambda p:p.stat().st_mtime, reverse=True)[:10]: print(p)
PY


Output:
?? autoshorts/review_package.py
?? data/comfyui_native_wan22_house_jump_5s_workflow.json
?? data/comfyui_native_wan22_rabbit_i2v_5s_workflow.json
?? data/comfyui_native_wan22_rabbit_i2v_crop_5s_workflow.json
?? data/comfyui_scene01_gpu_480x832_workflow.json
?? data/comfyui_scene01_gpu_preview_workflow.json
?? data/comfyui_scene01_probe_workflow.json
?? data/comfyui_wan22_scene01_416x736_t5legacy_workflow.json
?? data/comfyui_wan22_scene01_416x736_tiled_workflow.json
?? data/comfyui_wan22_scene01_416x736_workflow.json
?? data/comfyui_wan22_scene01_bright_416x736_workflow.json
?? data/comfyui_wan_micro_smoke_workflow.json
?? data/comfyui_wan_smoke_workflow.json
?? data/telegram_reviews/
?? scripts/fix_vertical_aspect.py
?? scripts/generate_telegram_review_packages.py
?? tests/test_review_package.py

PAT **/review*.json

PAT **/*review*
data/clip_reviews
data/comfyui_outputs/autoshorts_scene01_gpu_preview_00001_.mp4
data/comfyui_scene01_gpu_preview_workflow.json
data/telegram_reviews
data/previews
data/reviews

PAT **/*approval*
data/reviews/testbatch_002/ai-006-a-real-ai-assistant-needs-an-approval-gate.md

PAT **/*.mp4
data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/telegram_safe/scene_02_seed_101_FULLBLEED_1080x1920_30fps.mp4
data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/telegram_safe/scene_01_seed_101_FULLBLEED_1080x1920_30fps.mp4
data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_02_seed_101_1080x1920_sar1.mp4
data/aspect_fixed/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_01_seed_101_1080x1920_sar1.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_02/seed_303.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_02/seed_202.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_02/seed_101.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_01/seed_303.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_01/seed_202.mp4
data/generated_clips/hidden-money-systems-warum-ein-billiges-auto-teuer-sein-kann/scene_01/seed_101.mp4

PAT **/*package*


Explanation:
**Inspecting review packages**

I think I need to take a closer look at the review package, or maybe multiple packages. There might be some details I need to clarify or assess, so I'll check them thoroughly. It’s probably important to understand the contents and context before moving forward. Hmm, just making sure I’m on the right track here and considering all aspects carefully! I want to make sure everything is accounted for.


     1|"""Telegram review package generation for AutoShortsBot preview candidates."""
     2|
     3|from __future__ import annotations
     4|
     5|import json
     6|from pathlib import Path
     7|from typing import Any
     8|
     9|APPROVAL_COMMANDS = [
    10|    "Freigabe alle",
    11|    "Freigabe YouTube",
    12|    "Freigabe TikTok",
    13|    "Freigabe Instagram",
    14|    "Ablehnen",
    15|    "Ändern: <requested change>",
    16|]
    17|
    18|PLATFORM_LABELS = {
    19|    "youtube_shorts": "YouTube Shorts",
    20|    "tiktok": "TikTok",
    21|    "instagram_reels": "Instagram Reels",
    22|    "facebook_reels": "Facebook Reels",
    23|}
    24|
    25|
    26|def _idea_index(batch: dict[str, Any]) -> dict[str, dict[str, Any]]:
    27|    return {idea["id"]: idea for idea in batch.get("ideas", [])}
    28|
    29|
    30|def _format_platforms(platforms: list[str]) -> str:
    31|    return ", ".join(PLATFORM_LABELS.get(platform, platform) for platform in platforms)
    32|
    33|
    34|def _manifest_summary(manifest: dict[str, Any]) -> dict[str, Any]:
    35|    visual_items = [item for item in manifest["timeline"] if item["track"] == "visual"]
    36|    caption_items = [item for item in manifest["timeline"] if item["track"] == "captions"]
    37|    visual_types = sorted({item["type"] for item in visual_items})
    38|    return {
    39|        "duration_seconds": round(manifest["video"]["duration_ms"] / 1000),
    40|        "visual_scene_count": len(visual_items),
    41|        "caption_beat_count": len(caption_items),
    42|        "visual_types": visual_types,
    43|    }
    44|
    45|
    46|def build_review_package(
    47|    idea: dict[str, Any],
    48|    manifest: dict[str, Any],
    49|    preview_path: Path | None = None,
    50|) -> dict[str, Any]:
    51|    """Build structured Telegram review metadata for one rendered candidate."""
    52|    summary = _manifest_summary(manifest)
    53|    package = {
    54|        "id": idea["id"],
    55|        "title": idea["title"],
    56|        "pillar": idea["pillar"],
    57|        "platforms": idea["platform_target"],
    58|        "duration_seconds": summary["duration_seconds"],
    59|        "hook": idea["hook"],
    60|        "caption": idea["caption"],
    61|        "cta": idea["cta"],
    62|        "quality_score": idea["quality_score"],
    63|        "risk_notes": idea["risk_notes"],
    64|        "visual_scene_count": summary["visual_scene_count"],
    65|        "caption_beat_count": summary["caption_beat_count"],
    66|        "visual_types": summary["visual_types"],
    67|        "approval_commands": APPROVAL_COMMANDS,
    68|    }
    69|    if preview_path is not None:
    70|        package["preview_path"] = str(preview_path.resolve())
    71|    return package
    72|
    73|
    74|def format_review_package(package: dict[str, Any]) -> str:
    75|    """Format a Telegram-safe review message for one candidate."""
    76|    preview_line = ""
    77|    if package.get("preview_path"):
    78|        preview_line = f"\nMEDIA:{package['preview_path']}\n"
    79|
    80|    commands = "\n".join(f"- `{command}`" for command in package["approval_commands"])
    81|    visual_types = ", ".join(item.replace("_", " ") for item in package["visual_types"])
    82|    platforms = _format_platforms(package["platforms"])
    83|
    84|    return f"""## AutoShortsBot Review — {package['id']}
    85|{preview_line}
    86|**Titel:** {package['title']}
    87|**Pillar:** `{package['pillar']}`
    88|**Zielplattformen:** {platforms}
    89|**Dauer:** {package['duration_seconds']}s
    90|**Quality Score:** {package['quality_score']}/10
    91|
    92|**Hook:** {package['hook']}
    93|
    94|**Caption:** {package['caption']}
    95|**CTA:** {package['cta']}
    96|
    97|**Render-Check:** {package['visual_scene_count']} visual scenes, {package['caption_beat_count']} caption beats; types: {visual_types}.
    98|**Risk Notes:** {package['risk_notes']}
    99|
   100|**Freigabe-Befehle:**
   101|{commands}
   102|""".strip() + "\n"
   103|
   104|
   105|def write_review_packages(
   106|    batch: dict[str, Any],
   107|    manifest_dir: Path,
   108|    preview_dir: Path,
   109|    output_dir: Path,
   110|) -> list[Path]:
   111|    """Write one Telegram review package Markdown file per render manifest."""
   112|    output_dir.mkdir(parents=True, exist_ok=True)
   113|    ideas = _idea_index(batch)
   114|    paths: list[Path] = []
   115|
   116|    for manifest_path in sorted(manifest_dir.glob("*.json")):
   117|        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
   118|        video_id = manifest["video"]["id"]
   119|        if video_id not in ideas:
   120|            raise KeyError(f"No idea found for manifest video id: {video_id}")
   121|        preview_path = preview_dir / f"{manifest_path.stem}.mp4"
   122|        package = build_review_package(ideas[video_id], manifest, preview_path)
   123|        output_path = output_dir / f"{manifest_path.stem}.md"
   124|        output_path.write_text(format_review_package(package), encoding="utf-8")
   125|        paths.append(output_path)
   126|
   127|    return paths
   128|


     1|{
     2|  "name": "testbatch_002_retention_probe_no_faith",
     3|  "objective": "Test high-retention Life Optimization, AI Life Systems, and Attention Design ideas while pausing religious/FaithTok themes.",
     4|  "approval_mode": "telegram_required_before_posting",
     5|  "research_basis": "docs/retention_research.md",
     6|  "ideas": [
     7|    {
     8|      "id": "life-004",
     9|      "pillar": "life_optimization",
    10|      "title": "You do not need more discipline",
    11|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    12|      "duration_seconds": 38,
    13|      "hook": "You do not need more discipline. You need a smaller first step.",
    14|      "script_outline": "Challenge the discipline myth, show the two-minute starter, give one concrete example, close the loop: action creates clarity, not the other way around.",
    15|      "visual_concept": "Pattern interrupt: messy desk becomes one tiny next action with timer and captions.",
    16|      "caption": "Stop worshipping discipline. Shrink the first step.",
    17|      "cta": "Comment START if you want the template.",
    18|      "quality_score": 10,
    19|      "risk_notes": "No health claims; practical productivity framing."
    20|    },
    21|    {
    22|      "id": "life-005",
    23|      "pillar": "life_optimization",
    24|      "title": "Your to-do list is an anxiety museum",
    25|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    26|      "duration_seconds": 42,
    27|      "hook": "Your to-do list might not be a plan. It might be an anxiety museum.",
    28|      "script_outline": "Turn one overwhelming list into three buckets: do today, schedule later, delete. The payoff is one next action, not a prettier list.",
    29|      "visual_concept": "Before/after: chaotic paper list split into three labelled cards, fast cuts, large captions.",
    30|      "caption": "A list is not a plan until it chooses what you will ignore.",
    31|      "cta": "Which task belongs in delete?",
    32|      "quality_score": 9,
    33|      "risk_notes": "Avoid clinical anxiety language beyond metaphor."
    34|    },
    35|    {
    36|      "id": "life-006",
    37|      "pillar": "life_optimization",
    38|      "title": "The calendar exposes the lie",
    39|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    40|      "duration_seconds": 33,
    41|      "hook": "Your values are not in your notes app. They are in your calendar.",
    42|      "script_outline": "Show the gap between stated priorities and scheduled time. Pick one value, block 20 minutes this week, and protect it like a meeting.",
    43|      "visual_concept": "Calendar screen recording, one red empty week, one protected green block; crisp captions.",
    44|      "caption": "If it matters, it eventually needs a time block.",
    45|      "cta": "What value needs a calendar block this week?",
    46|      "quality_score": 9,
    47|      "risk_notes": "No personal calendar data shown."
    48|    },
    49|    {
    50|      "id": "ai-004",
    51|      "pillar": "ai_life_systems",
    52|      "title": "Stop asking AI to think for you",
    53|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    54|      "duration_seconds": 41,
    55|      "hook": "Stop asking AI to think for you. Ask it to argue with you.",
    56|      "script_outline": "Use AI as a challenger: list assumptions, find weak spots, suggest alternatives, then you decide. Better thinking, not outsourced thinking.",
    57|      "visual_concept": "Screen recording with prompt cards: assumptions, weak spots, alternatives, decision; human checkmark at end.",
    58|      "caption": "AI is more useful as a critic than as a guru.",
    59|      "cta": "Comment CRITIC if you want the prompt.",
    60|      "quality_score": 10,
    61|      "risk_notes": "Keep human decision authority explicit."
    62|    },
    63|    {
    64|      "id": "ai-005",
    65|      "pillar": "ai_life_systems",
    66|      "title": "The 4-box prompt for mental overload",
    67|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    68|      "duration_seconds": 61,
    69|      "hook": "If your brain has 47 tabs open, paste the mess into this 4-box prompt.",
    70|      "script_outline": "Brain dump to AI, ask for four boxes: tasks, worries, decisions, later. Then ask for one next action under two minutes. Show exact prompt structure.",
    71|      "visual_concept": "Before/after screen recording: chaotic paragraph becomes four clean cards; timer starts at the end.",
    72|      "caption": "Do not ask AI for motivation. Ask it for structure.",
    73|      "cta": "Comment RESET and I will turn this into a template.",
    74|      "quality_score": 10,
    75|      "risk_notes": "Not therapy; frame as organisation aid."
    76|    },
    77|    {
    78|      "id": "ai-006",
    79|      "pillar": "ai_life_systems",
    80|      "title": "A real AI assistant needs an approval gate",
    81|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    82|      "duration_seconds": 75,
    83|      "hook": "The most important part of an AI assistant is not the model. It is the approval gate.",
    84|      "script_outline": "Explain memory, tools, draft actions, and human approval before side effects. Show why powerful automation without confirmation becomes risk, not leverage.",
    85|      "visual_concept": "Animated system diagram: request, memory, tools, draft, approval gate, action; red stop line before posting/sending.",
    86|      "caption": "Automation without approval is not intelligence. It is liability.",
    87|      "cta": "Should I build this as a series?",
    88|      "quality_score": 10,
    89|      "risk_notes": "No secrets or real private data in visuals."
    90|    },
    91|    {
    92|      "id": "attention-001",
    93|      "pillar": "attention_design",
    94|      "title": "Your phone wins before breakfast",
    95|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
    96|      "duration_seconds": 36,
    97|      "hook": "If you touch your phone before your feet touch the floor, your day already has an owner.",
    98|      "script_outline": "Show morning phone reflex, then replace it with a 60-second no-input rule: stand up, water, light, one sentence plan.",
    99|      "visual_concept": "Close-up phone alarm, thumb reaches, freeze-frame, hard cut to water/light/notebook; bold captions.",
   100|      "caption": "Your first input shapes your first hour.",
   101|      "cta": "Try the no-input minute tomorrow.",
   102|      "quality_score": 10,
   103|      "risk_notes": "Avoid shame; frame as experiment."
   104|    },
   105|    {
   106|      "id": "attention-002",
   107|      "pillar": "attention_design",
   108|      "title": "The algorithm does not need you happy",
   109|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
   110|      "duration_seconds": 67,
   111|      "hook": "The algorithm does not need you happy. It needs you to stay.",
   112|      "script_outline": "Explain attention incentives without conspiracy. Then give three design moves: greyscale, remove default apps, set a leaving cue before opening feeds.",
   113|      "visual_concept": "Fast phone UI montage, greyscale switch, app removal, timer/leaving cue; not static slideshow.",
   114|      "caption": "Design beats willpower when the feed is engineered better than your intentions.",
   115|      "cta": "Which app gets moved off your home screen today?",
   116|      "quality_score": 10,
   117|      "risk_notes": "Do not imply specific hidden manipulation; speak generally about incentives."
   118|    },
   119|    {
   120|      "id": "attention-003",
   121|      "pillar": "attention_design",
   122|      "title": "The 5-second rule for opening any app",
   123|      "platform_target": ["youtube_shorts", "tiktok", "instagram_reels"],
   124|      "duration_seconds": 31,
   125|      "hook": "Before you open any app, give it a job description.",
   126|      "script_outline": "Pause for five seconds, say why you are opening it, set exit condition, then open. If there is no job, do not hire the app.",
   127|      "visual_concept": "Hand hovers over app, text overlay 'What is the job?', five-second countdown, exit condition card.",
   128|      "caption": "Apps become tools again when they need a job description.",
   129|      "cta": "Comment the app that steals the most time.",
   130|      "quality_score": 9,
   131|      "risk_notes": "No platform attack; behaviour-design framing."
   132|    }
   133|  ]
   134|}
   135|


Explanation:
**Preparing next post**

I’m figuring out the next steps for the user's project. I see there are existing top three previews to consider. The user mentioned the next video should be part of the post. I’m wondering if I should choose from drafts that haven’t been rendered yet. Perhaps I can create a new candidate based on the previous finalized video, maybe #7? I need to search for the current post using the session search.

Question: nächste video OR nächsten Post OR Freigabe OR final render OR TrueTraceShorts OR AutoShortsBot


Question: nächste video OR nächsten Post OR Freigabe OR final render OR TrueTraceShorts OR AutoShortsBot

Summary:
[Raw preview — summarization unavailable]
...[earlier conversation truncated]...

rue, "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.", "tags": ["shorts", "tiktok", "youtube-shorts", "reels", "creator-monetization", "content-automation", "social-media"], "related_skills": [], "content": "---\nname: social-media-shorts-automation\ndescription: Design, generate, review, publish
…[truncated]

Summary:
[Raw preview — summarization unavailable]
...[earlier conversation truncated]...

toshortsbot/AutoShortsBot/README.md\n+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md\n@@ -8,6 +8,7 @@\n \n Implemented foundation:\n \n+- `autoshorts.config.pillars` — content pillar registry, default AI/life-systems positioning, and pillar fit validation.\n - `autoshorts.ideas.candidate` — structured video candidate model and validation.\n - `autoshorts.ideas.testbatch` — testbatch validation and renderable-candidate filtering.\n -
…[truncated]

Summary:
[Raw preview — summarization unavailable]
...[earlier conversation truncated]...

56|\n   157|\n   158|def write_shot_plans(finals: list[dict[str, Any]], output_dir: Path) -> list[Path]:\n   159|    \"\"\"Write one Markdown shot plan per finalized script.\"\"\"\n   160|    output_dir.mkdir(parents=True, exist_ok=True)\n   161|    paths: list[Path] = []\n   162|    for final in finals:\n   163|        plan = build_shot_plan(final)\n   164|        path = output_dir / f\"{plan['id']}-{_slug(plan['title'])}.md\"\n   165|        path.write_t
…[truncated]


     1|# AutoShortsBot
     2|
     3|AutoShortsBot is a growth-oriented pipeline for creating social-media shorts with a human approval loop.
     4|
     5|## Current phase
     6|
     7|Phase 1 prepares a semi-automatic testbatch workflow:
     8|
     9|1. Generate/curate scripts for three content pillars.
    10|2. Score each idea before rendering.
    11|3. Render previews locally.
    12|4. Send drafts/previews to Telegram for approval.
    13|5. Post manually or semi-automatically only after approval.
    14|6. Capture analytics and feed results back into the next batch.
    15|
    16|## Content pillars
    17|
    18|- `life_optimization` — practical micro-systems for overloaded adults.
    19|- `ai_life_systems` — AI as a servant for better life systems.
    20|- `faithtok` — Christian encouragement for overloaded modern life.
    21|
    22|## First testbatch
    23|
    24|The first batch contains 9 ideas:
    25|
    26|- 3 Life Optimization
    27|- 3 AI Life Systems
    28|- 3 FaithTok
    29|
    30|Most are short growth candidates; selected items are 60+ seconds for TikTok Creator Rewards testing.
    31|
    32|## Secrets
    33|
    34|Do not commit secrets, `.env` files, OAuth tokens, generated credentials, or produced media files. Runtime secrets stay outside the repository.
    35|


     1|"""Local/free motion renderer for AutoShortsBot production jobs.
     2|
     3|The renderer intentionally uses only local Pillow/FFmpeg drawing and compositing.
     4|It does not call paid video-generation providers. The output is a deterministic
     5|motion prototype: procedural backgrounds, car/mechanism layers, kinetic captions,
     6|and beat-synced movement based on the free production job specs.
     7|"""
     8|
     9|from __future__ import annotations
    10|
    11|import json
    12|import math
    13|import shutil
    14|import subprocess
    15|import tempfile
    16|import textwrap
    17|from pathlib import Path
    18|from typing import Any
    19|
    20|from PIL import Image, ImageDraw, ImageFont
    21|
    22|PAID_VIDEO_PROVIDERS = {"kling", "hailuo", "runway", "veo", "pika", "luma"}
    23|LOCAL_RENDER_PROVIDERS = {"local_renderer", "local_ffmpeg"}
    24|
    25|
    26|PALETTE_BY_MODE = {
    27|    "cinematic_ai_video": ((7, 12, 25), (30, 64, 175), (248, 113, 113)),
    28|    "mechanism_animation": ((2, 6, 23), (15, 118, 110), (251, 191, 36)),
    29|    "kinetic_text_overlay": ((15, 23, 42), (88, 28, 135), (34, 211, 238)),
    30|    "screen_recording_simulation": ((8, 13, 28), (14, 116, 144), (96, 165, 250)),
    31|    "animated_card": ((17, 24, 39), (120, 53, 15), (251, 146, 60)),
    32|}
    33|
    34|
    35|def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    36|    candidates = [
    37|        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    38|        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    39|    ]
    40|    for path in candidates:
    41|        if Path(path).exists():
    42|            return ImageFont.truetype(path, size=size)
    43|    return ImageFont.load_default()
    44|
    45|
    46|def _wrap(text: str, width_chars: int) -> str:
    47|    return "\n".join(textwrap.wrap(" ".join(text.split()), width=max(8, width_chars)))
    48|
    49|
    50|def _gradient(width: int, height: int, start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image:
    51|    image = Image.new("RGB", (width, height), start)
    52|    draw = ImageDraw.Draw(image)
    53|    for y in range(height):
    54|        ratio = y / max(1, height - 1)
    55|        color = tuple(round(start[i] * (1 - ratio) + end[i] * ratio) for i in range(3))
    56|        draw.line([(0, y), (width, y)], fill=color)
    57|    return image
    58|
    59|
    60|def _ease(progress: float) -> float:
    61|    progress = min(1.0, max(0.0, progress))
    62|    return 0.5 - math.cos(progress * math.pi) / 2
    63|
    64|
    65|def _caption_layout(text: str, width: int, height: int, pulse: float) -> dict[str, Any]:
    66|    """Calculate a caption layout that stays inside the vertical safe area."""
    67|    text_max_width = round(width * 0.78)
    68|    max_box_width = round(width * 0.88)
    69|    max_block_height = round(height * 0.21)
    70|    pad = max(8, round(width * 0.035))
    71|    preferred_size = max(18, round(width * (0.06 + 0.012 * pulse)))
    72|    min_size = max(10, round(width * 0.038))
    73|
    74|    scratch = Image.new("RGB", (width, height))
    75|    draw = ImageDraw.Draw(scratch)
    76|    for size in range(preferred_size, min_size - 1, -1):
    77|        font = _font(size, bold=True)
    78|        # Width-based wrapping alone is too optimistic for German compound words;
    79|        # verify actual pixel width and shrink/re-wrap until it fits.
    80|        wrap_chars = max(8, round(text_max_width / (size * 0.5)))
    81|        wrapped = _wrap(text, wrap_chars)
    82|        raw_lines = wrapped.splitlines()
    83|        if len(raw_lines) > 4:
    84|            raw_lines = raw_lines[:3] + [" ".join(raw_lines[3:])]
    85|        spacing = max(4, round(size * 0.20))
    86|        lines = []
    87|        too_wide = False
    88|        for line in raw_lines:
    89|            box = draw.textbbox((0, 0), line, font=font)
    90|            line_width = box[2] - box[0]
    91|            line_height = box[3] - box[1]
    92|            if line_width > text_max_width:
    93|                too_wide = True
    94|                break
    95|            lines.append({"text": line, "width": line_width, "height": line_height})
    96|        block_height = sum(line["height"] for line in lines) + spacing * max(0, len(lines) - 1)
    97|        if lines and not too_wide and block_height <= max_block_height:
    98|            y = height - round(height * 0.205) - block_height // 2
    99|            box = [
   100|                round((width - max_box_width) / 2),
   101|                y - pad,
   102|                round((width + max_box_width) / 2),
   103|                y + block_height + pad,
   104|            ]
   105|            return {
   106|                "font": font,
   107|                "font_size": size,
   108|                "lines": lines,
   109|                "spacing": spacing,
   110|                "box": box,
   111|                "text_max_width": text_max_width,
   112|                "start_y": y,
   113|            }
   114|
   115|    font = _font(min_size, bold=True)
   116|    line = text
   117|    suffix = "…" if len(text) > 1 else ""
   118|    while line:
   119|        candidate = line.rstrip() + suffix
   120|        box = draw.textbbox((0, 0), candidate, font=font)
   121|        if box[2] - box[0] <= text_max_width:
   122|            break
   123|        line = line[:-1]
   124|    candidate = (line.rstrip() + suffix) if line else "…"
   125|    box = draw.textbbox((0, 0), candidate, font=font)
   126|    return {
   127|        "font": font,
   128|        "font_size": min_size,
   129|        "lines": [{"text": candidate, "width": box[2] - box[0], "height": box[3] - box[1]}],
   130|        "spacing": max(4, round(min_size * 0.20)),
   131|        "box": [round(width * 0.06), round(height * 0.72), round(width * 0.94), round(height * 0.84)],
   132|        "text_max_width": text_max_width,
   133|        "start_y": round(height * 0.76),
   134|    }
   135|
   136|
   137|def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
   138|    layout = _caption_layout(text, width, height, pulse)
   139|    draw.rounded_rectangle(
   140|        layout["box"],
   141|        radius=round(width * 0.035),
   142|        fill=(255, 255, 255),
   143|        outline=accent,
   144|        width=max(2, width // 180),
   145|    )
   146|    y = layout["start_y"]
   147|    for line in layout["lines"]:
   148|        x = (width - line["width"]) // 2
   149|        draw.text((x, y), line["text"], font=layout["font"], fill=(15, 23, 42))
   150|        y += line["height"] + layout["spacing"]
   151|
   152|
   153|def _draw_car(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int]) -> None:
   154|    cx = width // 2
   155|    cy = round(height * 0.43)
   156|    scale = width / 540
   157|    bob = round(math.sin(progress * math.tau * 2) * 6 * scale)
   158|    car_w = round(width * 0.58)
   159|    car_h = round(height * 0.12)
   160|    left = cx - car_w // 2
   161|    right = cx + car_w // 2
   162|    top = cy - car_h // 2 + bob
   163|    bottom = cy + car_h // 2 + bob
   164|    draw.rounded_rectangle([left, top, right, bottom], radius=round(24 * scale), fill=(226, 232, 240), outline=accent, width=max(2, round(4 * scale)))
   165|    cabin = [left + round(car_w * 0.23), top - round(car_h * 0.55), right - round(car_w * 0.25), top + round(car_h * 0.15)]
   166|    draw.rounded_rectangle(cabin, radius=round(14 * scale), fill=(148, 163, 184), outline=(226, 232, 240), width=max(1, round(2 * scale)))
   167|    wheel_r = round(width * 0.045)
   168|    for wx in (left + round(car_w * 0.22), right - round(car_w * 0.22)):
   169|        draw.ellipse([wx - wheel_r, bottom - wheel_r, wx + wheel_r, bottom + wheel_r], fill=(2, 6, 23), outline=(248, 250, 252), width=max(2, round(3 * scale)))
   170|        angle = progress * math.tau * 3
   171|        draw.line([wx, bottom, wx + math.cos(angle) * wheel_r, bottom + math.sin(angle) * wheel_r], fill=accent, width=max(1, round(3 * scale)))
   172|    # x-ray scan line and warning pulse
   173|    scan_x = left + round(car_w * _ease(progress))
   174|    draw.line([(scan_x, top - round(60 * scale)), (scan_x, bottom + round(70 * scale))], fill=accent, width=max(2, round(5 * scale)))
   175|    pulse_r = round((30 + 38 * _ease((progress * 2) % 1)) * scale)
   176|    draw.ellipse([cx - pulse_r, cy - pulse_r + bob, cx + pulse_r, cy + pulse_r + bob], outline=accent, width=max(1, round(3 * scale)))
   177|
   178|
   179|def _draw_mechanism_layers(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int], twist: bool) -> None:
   180|    labels = ["Preis", "Reparatur", "Standtage", "Marge", "Liquidität"]
   181|    visible = max(1, min(len(labels), math.ceil(progress * len(labels))))
   182|    top = round(height * 0.24)
   183|    layer_h = round(height * 0.072)
   184|    gap = round(height * 0.022)
   185|    for index, label in enumerate(labels[:visible]):
   186|        p = _ease(min(1.0, max(0.0, progress * len(labels) - index)))
   187|        x_offset = round((1 - p) * width * 0.45)
   188|        y = top + index * (layer_h + gap)
   189|        color = accent if (twist and index == 0) else (226, 232, 240)
   190|        fill = (30, 41, 59) if not (twist and index == 0) else (127, 29, 29)
   191|        draw.rounded_rectangle(
   192|            [round(width * 0.12) + x_offset, y, round(width * 0.88) + x_offset, y + layer_h],
   193|            radius=round(width * 0.026),
   194|            fill=fill,
   195|            outline=color,
   196|            width=max(2, width // 180),
   197|        )
   198|        f = _font(max(14, round(width * 0.046)), bold=True)
   199|        draw.text((round(width * 0.17) + x_offset, y + round(layer_h * 0.22)), label, font=f, fill=(248, 250, 252))
   200|        meter_w = round(width * 0.24 * p)
   201|        draw.rounded_rectangle(
   202|            [round(width * 0.58) + x_offset, y + round(layer_h * 0.35), round(width * 0.58) + x_offset + meter_w, y + round(layer_h * 0.63)],
   203|            radius=round(width * 0.015),
   204|            fill=color,
   205|        )
   206|    car_y = round(height * 0.72)
   207|    draw.line([(round(width * 0.5), top + visible * (layer_h + gap)), (round(width * 0.5), car_y - round(height * 0.05))], fill=accent, width=max(2, width // 160))
   208|    _draw_car(draw, width, height, progress, accent)
   209|
   210|
   211|def _draw_kinetic_words(draw: ImageDraw.ImageDraw, width: int, height: int, text: str, progress: float, accent: tuple[int, int, int]) -> None:
   212|    words = text.split()
   213|    current = words[: max(1, min(len(words), math.ceil(progress * max(1, len(words)))))]
   214|    main = " ".join(current[-5:])
   215|    size = max(28, round(width * (0.105 + 0.025 * math.sin(progress * math.tau * 3))))
   216|    font = _font(size, bold=True)
   217|    wrapped = _wrap(main, max(8, round(width / (size * 0.48))))
   218|    lines = wrapped.splitlines()[:3]
   219|    total_h = 0
   220|    boxes = []
   221|    for line in lines:
   222|        box = draw.textbbox((0, 0), line, font=font)
   223|        boxes.append(box)
   224|        total_h += box[3] - box[1]
   225|    y = height // 2 - total_h // 2
   226|    for line, box in zip(lines, boxes):
   227|        line_w = box[2] - box[0]
   228|        x = (width - line_w) // 2 + round(math.sin(progress * math.tau * 2) * width * 0.025)
   229|        draw.text((x + 3, y + 3), line, font=font, fill=(15, 23, 42))
   230|        draw.text((x, y), line, font=font, fill=(248, 250, 252))
   231|        y += box[3] - box[1] + round(size * 0.18)
   232|    glow = round(width * (0.12 + 0.08 * _ease((progress * 3) % 1)))
   233|    draw.ellipse([width // 2 - glow, round(height * 0.18) - glow, width // 2 + glow, round(height * 0.18) + glow], outline=accent, width=max(2, width // 140))
   234|
   235|
   236|def _render_frame(job: dict[str, Any], width: int, height: int, frame_index: int, total_frames: int) -> Image.Image:
   237|    mode = job["asset_mode"]
   238|    start, end, accent = PALETTE_BY_MODE.get(mode, PALETTE_BY_MODE["cinematic_ai_video"])
   239|    progress = frame_index / max(1, total_frames - 1)
   240|    # simulated handheld push-in: brighten and offset gradient slightly over time
   241|    image = _gradient(width, height, start, end)
   242|    draw = ImageDraw.Draw(image)
   243|
   244|    for i in range(9):
   245|        y = round((i / 8) * height + math.sin(progress * math.tau + i) * height * 0.014)
   246|        draw.line([(0, y), (width, y + round(height * 0.08))], fill=tuple(min(255, c + 16) for c in start), width=max(1, width // 90))
   247|
   248|    if mode == "mechanism_animation":
   249|        _draw_mechanism_layers(draw, width, height, progress, accent, twist="teuerste Fehler" in job.get("overlay_text", ""))
   250|    elif mode == "kinetic_text_overlay":
   251|        _draw_kinetic_words(draw, width, height, job["overlay_text"], progress, accent)
   252|    else:
   253|        _draw_car(draw, width, height, progress, accent)
   254|        # local parallax objects / risk tags
   255|        tag_font = _font(max(12, round(width * 0.038)), bold=True)
   256|        for idx, label in enumerate(["-4'000?", "Risiko", "Timing"]):
   257|            p = (progress + idx * 0.18) % 1
   258|            x = round(width * (0.1 + 0.72 * p))
   259|            y = round(height * (0.17 + 0.08 * idx + 0.015 * math.sin(progress * math.tau * 2 + idx)))
   260|            draw.rounded_rectangle([x, y, x + round(width * 0.22), y + round(height * 0.045)], radius=round(width * 0.02), fill=(248, 250, 252), outline=accent, width=max(1, width // 220))
   261|

Use offset=261 to continue reading (showing 1-260 of 368 lines)


   261|            draw.text((x + round(width * 0.018), y + round(height * 0.01)), label, font=tag_font, fill=(15, 23, 42))
   262|
   263|    title_font = _font(max(11, round(width * 0.031)), bold=False)
   264|    draw.text((round(width * 0.06), round(height * 0.045)), f"{job['scene_id']} · {mode.replace('_', ' ')}", font=title_font, fill=(226, 232, 240))
   265|    _draw_caption(draw, job["overlay_text"], width, height, accent, _ease((progress * 4) % 1))
   266|    return image
   267|
   268|
   269|def local_render_jobs(production_jobs: dict[str, Any]) -> list[dict[str, Any]]:
   270|    """Return only executable local render jobs, excluding text-only LLM refinement."""
   271|    providers = {job["provider"] for job in production_jobs["jobs"]}
   272|    forbidden = providers & PAID_VIDEO_PROVIDERS
   273|    if forbidden:
   274|        raise ValueError(f"Paid video providers are forbidden in local render: {sorted(forbidden)}")
   275|    return [job for job in production_jobs["jobs"] if job["provider"] in LOCAL_RENDER_PROVIDERS]
   276|
   277|
   278|def _write_frames(render_jobs: list[dict[str, Any]], frame_dir: Path, width: int, height: int, fps: int) -> tuple[int, int]:
   279|    frame_dir.mkdir(parents=True, exist_ok=True)
   280|    frame_number = 0
   281|    motion_events = 0
   282|    for job in render_jobs:
   283|        scene_frames = max(1, round(float(job["duration_seconds"]) * fps))
   284|        motion_events += max(2, math.ceil(float(job["duration_seconds"]) / 2))
   285|        for local_index in range(scene_frames):
   286|            image = _render_frame(job, width, height, local_index, scene_frames)
   287|            image.save(frame_dir / f"frame_{frame_number:06d}.png")
   288|            frame_number += 1
   289|    return frame_number, motion_events
   290|
   291|
   292|def render_local_motion_video(
   293|    production_jobs: dict[str, Any],
   294|    output_path: Path,
   295|    width: int = 540,
   296|    height: int = 960,
   297|    fps: int = 24,
   298|) -> dict[str, Any]:
   299|    """Render local/free production jobs into a vertical MP4 and metadata JSON."""
   300|    ffmpeg = shutil.which("ffmpeg")
   301|    if not ffmpeg:
   302|        raise RuntimeError("ffmpeg is required to render local motion MP4 files")
   303|
   304|    render_jobs = local_render_jobs(production_jobs)
   305|    output_path.parent.mkdir(parents=True, exist_ok=True)
   306|    total_duration: float = sum((float(job["duration_seconds"]) for job in render_jobs), 0.0)
   307|
   308|    with tempfile.TemporaryDirectory(prefix="autoshorts_local_motion_") as tmp:
   309|        frame_dir = Path(tmp) / "frames"
   310|        frame_count, motion_event_count = _write_frames(render_jobs, frame_dir, width, height, fps)
   311|        command = [
   312|            ffmpeg,
   313|            "-y",
   314|            "-framerate",
   315|            str(fps),
   316|            "-i",
   317|            str(frame_dir / "frame_%06d.png"),
   318|            "-vf",
   319|            "format=yuv420p",
   320|            "-movflags",
   321|            "+faststart",
   322|            str(output_path),
   323|        ]
   324|        completed = subprocess.run(command, check=True, capture_output=True, text=True)
   325|
   326|    metadata_path = output_path.with_suffix(".json")
   327|    metadata = {
   328|        "schema_version": "local_motion_render.v1",
   329|        "video_id": production_jobs["video"]["id"],
   330|        "source_schema_version": production_jobs["schema_version"],
   331|        "output_path": str(output_path),
   332|        "width": width,
   333|        "height": height,
   334|        "fps": fps,
   335|        "frame_count": frame_count,
   336|        "scene_count": len(render_jobs),
   337|        "rendered_duration_seconds": int(total_duration) if total_duration.is_integer() else total_duration,
   338|        "motion_event_count": motion_event_count,
   339|        "paid_credit_providers_used": [],
   340|        "budget_policy": production_jobs["budget_policy"],
   341|        "scenes": [
   342|            {
   343|                "scene_id": job["scene_id"],
   344|                "asset_mode": job["asset_mode"],
   345|                "provider": job["provider"],
   346|                "execution_mode": job["execution_mode"],
   347|                "duration_seconds": job["duration_seconds"],
   348|                "motion_directive": job["motion_directive"],
   349|            }
   350|            for job in render_jobs
   351|        ],
   352|        "ffmpeg_stderr_tail": completed.stderr[-2000:],
   353|    }
   354|    metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
   355|
   356|    return {
   357|        "output_path": output_path,
   358|        "metadata_path": metadata_path,
   359|        "video_id": production_jobs["video"]["id"],
   360|        "width": width,
   361|        "height": height,
   362|        "fps": fps,
   363|        "frame_count": frame_count,
   364|        "scene_count": len(render_jobs),
   365|        "rendered_duration_seconds": metadata["rendered_duration_seconds"],
   366|        "motion_event_count": motion_event_count,
   367|        "paid_credit_providers_used": [],
   368|    }
   369|


     1|"""Deterministic preview renderer for AutoShortsBot render manifests.
     2|
     3|This v1 renderer intentionally uses manifest-driven cards/placeholders instead of
     4|AI-generated video. It renders a simple vertical MP4 suitable for timing and
     5|approval review before investing in polished assets.
     6|"""
     7|
     8|from __future__ import annotations
     9|
    10|import shutil
    11|import subprocess
    12|import tempfile
    13|import textwrap
    14|from pathlib import Path
    15|from typing import Any
    16|
    17|from PIL import Image, ImageDraw, ImageFont
    18|
    19|BACKGROUND_BY_TYPE = {
    20|    "kinetic_text": ((19, 23, 38), (79, 70, 229)),
    21|    "screen_recording": ((15, 23, 42), (14, 165, 233)),
    22|    "card_animation": ((17, 24, 39), (245, 158, 11)),
    23|    "stock_broll": ((28, 25, 23), (34, 197, 94)),
    24|    "generated_background": ((24, 24, 27), (168, 85, 247)),
    25|}
    26|
    27|
    28|def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    29|    candidates = [
    30|        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    31|        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    32|    ]
    33|    for path in candidates:
    34|        if Path(path).exists():
    35|            return ImageFont.truetype(path, size=size)
    36|    return ImageFont.load_default()
    37|
    38|
    39|def _wrap_text(text: str, width_chars: int) -> str:
    40|    return "\n".join(textwrap.wrap(" ".join(text.split()), width=width_chars))
    41|
    42|
    43|def _text_block_size(draw: ImageDraw.ImageDraw, text: str, font: Any, spacing: int) -> tuple[int, int]:
    44|    lines = text.splitlines() or [""]
    45|    boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
    46|    width = max((box[2] - box[0] for box in boxes), default=0)
    47|    height = sum(box[3] - box[1] for box in boxes) + spacing * max(0, len(lines) - 1)
    48|    return int(width), int(height)
    49|
    50|
    51|def _fit_text_block(
    52|    draw: ImageDraw.ImageDraw,
    53|    text: str,
    54|    max_width: int,
    55|    max_height: int,
    56|    max_lines: int,
    57|    preferred_size: int,
    58|    min_size: int,
    59|    bold: bool,
    60|) -> dict[str, Any]:
    61|    """Choose a readable font size and wrapping that fits a text box."""
    62|    cleaned = " ".join(text.split())
    63|    for size in range(preferred_size, min_size - 1, -1):
    64|        wrap_chars = max(10, round(max_width / (size * 0.54)))
    65|        wrapped = _wrap_text(cleaned, wrap_chars)
    66|        lines = wrapped.splitlines()
    67|        font = _font(size, bold=bold)
    68|        spacing = max(4, round(size * 0.25))
    69|        block_width, block_height = _text_block_size(draw, wrapped, font, spacing)
    70|        if len(lines) <= max_lines and block_width <= max_width and block_height <= max_height:
    71|            return {
    72|                "text": wrapped,
    73|                "font": font,
    74|                "font_size": size,
    75|                "line_count": len(lines),
    76|                "spacing": spacing,
    77|            }
    78|
    79|    fallback_font = _font(min_size, bold=bold)
    80|    fallback_chars = max(10, round(max_width / (min_size * 0.54)))
    81|    fallback = _wrap_text(cleaned, fallback_chars)
    82|    return {
    83|        "text": fallback,
    84|        "font": fallback_font,
    85|        "font_size": min_size,
    86|        "line_count": len(fallback.splitlines()),
    87|        "spacing": max(4, round(min_size * 0.25)),
    88|    }
    89|
    90|
    91|def _gradient(width: int, height: int, start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image:
    92|    image = Image.new("RGB", (width, height), start)
    93|    draw = ImageDraw.Draw(image)
    94|    for y in range(height):
    95|        ratio = y / max(1, height - 1)
    96|        color = tuple(round(start[i] * (1 - ratio) + end[i] * ratio) for i in range(3))
    97|        draw.line([(0, y), (width, y)], fill=color)
    98|    return image
    99|
   100|
   101|def _draw_multiline_center(
   102|    draw: ImageDraw.ImageDraw,
   103|    xy: tuple[int, int],
   104|    text: str,
   105|    font: Any,
   106|    fill: tuple[int, int, int],
   107|    spacing: int = 8,
   108|) -> None:
   109|    lines = text.splitlines()
   110|    line_boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
   111|    line_heights = [box[3] - box[1] for box in line_boxes]
   112|    total_height = sum(line_heights) + spacing * max(0, len(lines) - 1)
   113|    y = xy[1] - total_height // 2
   114|    for line, box, line_height in zip(lines, line_boxes, line_heights):
   115|        line_width = box[2] - box[0]
   116|        draw.text((xy[0] - line_width // 2, y), line, font=font, fill=fill)
   117|        y += line_height + spacing
   118|
   119|
   120|def _render_scene_image(
   121|    item: dict[str, Any], manifest: dict[str, Any], width: int, height: int
   122|) -> tuple[Image.Image, dict[str, int]]:
   123|    start, end = BACKGROUND_BY_TYPE.get(item["type"], ((24, 24, 27), (64, 64, 64)))
   124|    image = _gradient(width, height, start, end)
   125|    draw = ImageDraw.Draw(image)
   126|
   127|    margin = round(width * 0.075)
   128|    title_font = _font(max(18, round(width * 0.06)), bold=True)
   129|    label_font = _font(max(12, round(width * 0.032)), bold=False)
   130|    small_font = _font(max(12, round(width * 0.034)), bold=False)
   131|
   132|    # Safe area and content card.
   133|    card_top = round(height * 0.18)
   134|    card_bottom = round(height * 0.78)
   135|    draw.rounded_rectangle(
   136|        [margin, card_top, width - margin, card_bottom],
   137|        radius=round(width * 0.04),
   138|        fill=(255, 255, 255),
   139|        outline=(226, 232, 240),
   140|        width=max(2, width // 180),
   141|    )
   142|
   143|    draw.text((margin, round(height * 0.055)), manifest["video"]["id"], font=label_font, fill=(226, 232, 240))
   144|    draw.text((margin, round(height * 0.09)), item["type"].replace("_", " ").upper(), font=title_font, fill=(255, 255, 255))
   145|
   146|    scene_label = item["id"].replace("_", " ").upper()
   147|    draw.text((margin * 1.4, card_top + margin), scene_label, font=small_font, fill=(71, 85, 105))
   148|
   149|    main_fit = _fit_text_block(
   150|        draw,
   151|        item["text"],
   152|        max_width=width - margin * 4,
   153|        max_height=round(height * 0.24),
   154|        max_lines=4,
   155|        preferred_size=max(22, round(width * 0.078)),
   156|        min_size=max(18, round(width * 0.063)),
   157|        bold=True,
   158|    )
   159|    _draw_multiline_center(
   160|        draw,
   161|        (width // 2, round(height * 0.45)),
   162|        main_fit["text"],
   163|        main_fit["font"],
   164|        (15, 23, 42),
   165|        spacing=main_fit["spacing"],
   166|    )
   167|
   168|    retention_fit = _fit_text_block(
   169|        draw,
   170|        item.get("retention_goal", ""),
   171|        max_width=width - margin * 4,
   172|        max_height=round(height * 0.14),
   173|        max_lines=3,
   174|        preferred_size=max(14, round(width * 0.048)),
   175|        min_size=max(12, round(width * 0.041)),
   176|        bold=False,
   177|    )
   178|    _draw_multiline_center(
   179|        draw,
   180|        (width // 2, round(height * 0.69)),
   181|        retention_fit["text"],
   182|        retention_fit["font"],
   183|        (71, 85, 105),
   184|        spacing=retention_fit["spacing"],
   185|    )
   186|
   187|    timing = f"{item['start_ms'] / 1000:.1f}s – {(item['start_ms'] + item['duration_ms']) / 1000:.1f}s"
   188|    draw.text((margin, height - round(height * 0.075)), timing, font=label_font, fill=(226, 232, 240))
   189|    draw.text((width - margin - draw.textlength("PREVIEW", font=label_font), height - round(height * 0.075)), "PREVIEW", font=label_font, fill=(226, 232, 240))
   190|    return image, {
   191|        "main_font_size": main_fit["font_size"],
   192|        "main_line_count": main_fit["line_count"],
   193|        "retention_font_size": retention_fit["font_size"],
   194|        "retention_line_count": retention_fit["line_count"],
   195|    }
   196|
   197|
   198|def build_preview_frames(manifest: dict[str, Any], width: int = 540, height: int = 960) -> list[dict[str, Any]]:
   199|    """Build one representative image per visual timeline item."""
   200|    visual_items = [item for item in manifest["timeline"] if item["track"] == "visual"]
   201|    frames: list[dict[str, Any]] = []
   202|    for item in visual_items:
   203|        image, typography = _render_scene_image(item, manifest, width, height)
   204|        frames.append(
   205|            {
   206|                "scene_id": item["id"],
   207|                "start_ms": item["start_ms"],
   208|                "duration_ms": item["duration_ms"],
   209|                "text": item["text"],
   210|                "typography": typography,
   211|                "image": image,
   212|            }
   213|        )
   214|    return frames
   215|
   216|
   217|def write_frame_images(frames: list[dict[str, Any]], output_dir: Path) -> list[Path]:
   218|    """Write preview frames as numbered PNG files."""
   219|    output_dir.mkdir(parents=True, exist_ok=True)
   220|    paths: list[Path] = []
   221|    for index, frame in enumerate(frames, start=1):
   222|        path = output_dir / f"frame_{index:03d}_{frame['scene_id']}.png"
   223|        frame["image"].save(path)
   224|        paths.append(path)
   225|    return paths
   226|
   227|
   228|def _write_concat_file(frames: list[dict[str, Any]], image_paths: list[Path], concat_path: Path) -> None:
   229|    lines: list[str] = []
   230|    for frame, image_path in zip(frames, image_paths):
   231|        seconds = max(0.5, frame["duration_ms"] / 1000)
   232|        lines.append(f"file '{image_path.as_posix()}'")
   233|        lines.append(f"duration {seconds:.3f}")
   234|    if image_paths:
   235|        lines.append(f"file '{image_paths[-1].as_posix()}'")
   236|    concat_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
   237|
   238|
   239|def render_preview_video(
   240|    manifest: dict[str, Any],
   241|    output_path: Path,
   242|    width: int = 540,
   243|    height: int = 960,
   244|    fps: int = 24,
   245|) -> dict[str, Any]:
   246|    """Render a manifest-driven preview MP4 using Pillow frames and ffmpeg."""
   247|    ffmpeg = shutil.which("ffmpeg")
   248|    if not ffmpeg:
   249|        raise RuntimeError("ffmpeg is required to render preview MP4 files")
   250|
   251|    output_path.parent.mkdir(parents=True, exist_ok=True)
   252|    frames = build_preview_frames(manifest, width=width, height=height)
   253|    with tempfile.TemporaryDirectory(prefix="autoshorts_preview_") as tmp:
   254|        tmp_dir = Path(tmp)
   255|        image_paths = write_frame_images(frames, tmp_dir / "frames")
   256|        concat_path = tmp_dir / "concat.txt"
   257|        _write_concat_file(frames, image_paths, concat_path)
   258|        command = [
   259|            ffmpeg,
   260|            "-y",
   261|

Use offset=261 to continue reading (showing 1-260 of 279 lines)


   239|def render_preview_video(
   240|    manifest: dict[str, Any],
   241|    output_path: Path,
   242|    width: int = 540,
   243|    height: int = 960,
   244|    fps: int = 24,
   245|) -> dict[str, Any]:
   246|    """Render a manifest-driven preview MP4 using Pillow frames and ffmpeg."""
   247|    ffmpeg = shutil.which("ffmpeg")
   248|    if not ffmpeg:
   249|        raise RuntimeError("ffmpeg is required to render preview MP4 files")
   250|
   251|    output_path.parent.mkdir(parents=True, exist_ok=True)
   252|    frames = build_preview_frames(manifest, width=width, height=height)
   253|    with tempfile.TemporaryDirectory(prefix="autoshorts_preview_") as tmp:
   254|        tmp_dir = Path(tmp)
   255|        image_paths = write_frame_images(frames, tmp_dir / "frames")
   256|        concat_path = tmp_dir / "concat.txt"
   257|        _write_concat_file(frames, image_paths, concat_path)
   258|        command = [
   259|            ffmpeg,
   260|            "-y",
   261|            "-f",
   262|            "concat",
   263|            "-safe",
   264|            "0",
   265|            "-i",
   266|            str(concat_path),
   267|            "-vf",
   268|            f"fps={fps},format=yuv420p",
   269|            "-movflags",
   270|            "+faststart",
   271|            str(output_path),
   272|        ]
   273|        completed = subprocess.run(command, check=True, capture_output=True, text=True)
   274|    return {
   275|        "output_path": output_path,
   276|        "frame_count": len(frames),
   277|        "fps": fps,
   278|        "stderr": completed.stderr,
   279|    }
   280|


Explanation:
**Preparing content for next post**

I think I should consider using the existing render manifest to help streamline things. The user asked for the next post, so I might need to package everything for "attention-002." I'll have to check if there's a TTS (text-to-speech) pipeline we can use for the final video. It could be useful to enhance the content! I'll look into the options for that and see how to proceed.


Explanation:
**Planning video content**

I'm considering using the TTS tool to generate an MP3 voiceover, but I'm not sure about including active-word subtitles since timing could be tricky. Instead, I might create phrase captions based on the script. For the user's video, it should feature relevant content. I think the next post could focus on "Stop asking AI to think for you," as it’s short and looks good. I wonder if I need TTS for the video audio or if I can stick with a default voice.
**Configuring TTS settings**

I'm thinking about the TTS tool for the final English video, but I noticed it might default to de-DE, which isn’t ideal. I'm wondering if the user configured it this way or if there's only a voice option available. I could use the local edge-tts command with an English voice instead. I should check if I need additional skills for edge-tts. It's important to ensure the TTS configuration aligns with the video's language needs!


edge-tts

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

# Edge TTS

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

## When to Use

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

## Prerequisites

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

## User Voice Preference

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

## German Voices Available

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

## Usage

### Basic Synthesis

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

### Testing a Voice

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

### Listing Available Voices

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

### Configuring in Hermes

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

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

## Pitfalls

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

## Shorts / AutoShorts Baseline Voice Use

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

For caption-synchronised Shorts, request word-boundary metadata rather than approximating word timings across sentence SRT spans:

```python
communicate = edge_tts.Communicate(text, voice, rate='+0%', boundary='WordBoundary')
async for chunk in communicate.stream():
    if chunk['type'] == 'audio':
        ...
    elif chunk['type'] == 'WordBoundary':
        start_seconds = chunk['offset'] / 10_000_000
        duration_seconds = chunk['duration'] / 10_000_000
        word = chunk['text']
```

Use these offsets to drive static 3–7 word caption phrases with only the active spoken word highlighted. This avoids the irritating lag/lead caused by sentence-duration approximation and supports the user's preferred Hormozi/TikTok-style subtitle feel.

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

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

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

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

Then mux/burn captions in a separate FFmpeg boundary:

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

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

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

## Linked Resources

- `references/german-voice-comparison.md` — German male voice comparison data from session 2026-05-07, with recommendations for voice selection.
- `references/autoshorts-edge-tts-audio-caption.md` — EdgeTTS boundary pattern for AutoShorts audio/caption review MP4s, including ffprobe duration alignment and the `-shortest` pitfall.

## Voice Switching Workflow

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

## Related Skills

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

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


Explanation:
**Checking installation and script creation**

I need to check if edge-tts is installed to build a video using Python with edge_tts's word boundaries. I should check for the installation and write a script to generate a local package in data/post_candidates/ai-004, including an mp4 file. I wonder if any code modifications are necessary. I'll probably use a Python script with execute_code to generate the artifact, linking terminal commands and ensuring everything runs smoothly, avoiding heredoc for file creation.
**Creating voice script and commands**

I see that it suggests not to use echo/cat heredoc to create files, but running Python code in heredoc as a terminal command might still be acceptable. I think a better approach is to write the script render_next_post_ai004.py, incorporating a candidate for ai-004. The final voice script should convey a message about how to effectively use AI as a critic rather than just a thinker.

Additionally, I'll need to include edge_tts's async function for audio and word boundaries, and identify any required command packages.
**Exploring edge_tts and rendering requirements**

I could use the edge_tts library, but if it's missing, I might need to install it via pip using the terminal. For the voice, I'd like to use en-GB-RyanNeural. The project will involve rendering at 1080x1920 resolution at 30 fps with a dark gradient background, consisting of 8 scenes featuring UI cards.

I'll implement word boundaries to phrase segments while highlighting active words. If this proves too complex, I might resort to phrasing captions by time instead. I need to generate frames for each segment as well!


terminal

python - <<'PY'
try:
 import edge_tts
 print('edge_tts OK', edge_tts.__version__ if hasattr(edge_tts,'__version__') else '')
except Exception as e:
 print('edge_tts missing', type(e).__name__, str(e))
PY
which ffmpeg && which ffprobe


Output:
edge_tts OK 7.2.7
/usr/bin/ffmpeg
/usr/bin/ffprobe