Py-Shorts-Studio: Virale 2D-Vektor-Erklär-Engine
py-shorts-studio ist ein leistungsstarkes Python-Framework für die Erstellung vertikaler (1080x1920, 9:16), 60 FPS Bewegungsgrafik-Erklärvideos (Shorts). Es umgeht Festplatten-E/A, indem es PyCairo-Vektorframes direkt an FFmpeg stdin streamt, Voiceovers mit aktiven Wort-Karaoke-Untertiteln ausrichtet, benutzerdefinierte Soundeffekte synthetisiert und Multi-Track-Audio mischt.
1. Nahtloser Audio- + Visueller Match-Cut-Loop (Unendliche Bindung)
Um die durchschnittliche prozentuale Ansicht zu maximieren (>100% Loop-Bindung bei YouTube Shorts & Instagram Reels), muss jedes generierte Short sowohl eine Audio-Satzbrücke als auch einen pixelidentischen visuellen Match-Cut erzwingen.
A. Die Satzbrücken-Skriptregel
Wenn YouTube ein Short loopt, verbindet sich das Ende des Audios augenblicklich mit dem Anfang. Das Skript muss dies ausnutzen:
- Eröffnungs-Hook: MUSS sofort mit einer scharfen Warum / Wie / Was-Frage beginnen.
- Beispiel:
"Warum würde Iron Mans Anzug Tony Stark in drei Sekunden töten?" - Beispiel:
"Warum zerfallen moderne Autobahnen in fünfzehn Jahren, während römische Straßen zweitausend halten?"
- Beispiel:
- Abschließende Einleitungsphrase: Der letzte Satz MUSS mit einer nachlaufenden, erwartungsvollen Einleitungsphrase enden, anstatt mit einem endgültigen Abschied oder einem Aufruf zum Abonnieren:
- Beispiel:
"...weshalb die Leute immer noch fragen," - Beispiel:
"...also, wenn dich das nächste Mal jemand fragt," - Beispiel:
"...und das erklärt,"
- Beispiel:
- Kontinuierliche grammatikalische Schleife: Wenn das Video von der nachlaufenden Phrase in die Eröffnungsfrage loopt, bildet es einen nahtlosen grammatikalischen Satz:
"...weshalb die Leute immer noch fragen — Warum würde Iron Mans Anzug Tony Stark in drei Sekunden töten?"
- Keine nachlaufende Stille:
total_dauer = audio_dauer + 0.05(NIEMALS0.4soder0.75snachlaufende Totenstille hinzufügen).- Jede Pause am Ende zerstört die Illusion einer Endlosschleife. Die Audiospule muss das erste Wort des Neustarts ohne wahrnehmbare Lücke treffen.
B. Pixelidentischer Frame-0-Match-Cut
Der allererste Frame ($t = 0.0\text{s}$) und der allerletzte Frame ($t = \text{total_dauer}$) müssen visuell identisch sein, damit der Betrachter nicht erkennen kann, wann sich das Video wiederholt.
Implementierungsmuster: draw_anchor_state
Jedes Projektskript muss eine gemeinsame draw_anchor_state(ctx, zoom=1.0, pan=(0, 0))-Funktion definieren, die das primäre „Helden“-Visuelle und das obere Banner enthält.
def draw_anchor_state(ctx: cairo.Context, zoom: float = 1.0, pan: tuple[float, float] = (0.0, 0.0)) -> None:
"""Rendert das master Helden-Diagramm und das obere Banner mit einem beliebigen Zoom/Pan-Zentrum.
Wird sowohl von Szene 1 (Eröffnungs-Ease-Out-Zoom) als auch von der letzten Szene (Abschluss-Ease-In-Zoom) verwendet,
um einen pixelidentischen Match-Cut über die Loop-Grenze hinweg zu gewährleisten.
"""
ctx.save()
# Zentrumsursprungstransformation (1080x1920 Mitte ist 540, 960)
ctx.translate(540.0 + pan[0], 960.0 + pan[1])
ctx.scale(zoom, zoom)
ctx.translate(-540.0, -960.0)
# 1. Hintergrund
draw_dark_bg(ctx, 1080, 1920)
# 2. Oberes Banner
draw_top_banner(ctx, "HELDEN-THEMA-TITEL", "TECHNIK-ERKLÄRVIDEO", progress=1.0, theme="dark")
# 3. Helden-Visuelles / Diagramm im Aktionsbereich
# (Hier zentrales Diagramm, Querschnitt oder Apparatur zeichnen)
...
ctx.restore()
Match-Cut-Zoom-Dynamik (0.0s – 1.2s & Letzte 1.2s)
- Eröffnung ($t = 0.0\text{s} \to 1.2\text{s}$):
- Startet bei $t = 0.0\text{s}$ stark hineingezoomt auf das Fokusdiagramm mit
zoom = 1.45. - Über $0.0\text{s} \to 1.2\text{s}$ sanft herauszoomen zur Gesamtansicht (
zoom = 1.0) mittelsease_out_cubic:if time_sec <= 1.2: zoom_t = ease_out_cubic(time_sec / 1.2) cur_zoom = lerp(1.45, 1.0, zoom_t) draw_anchor_state(ctx, zoom=cur_zoom)
- Startet bei $t = 0.0\text{s}$ stark hineingezoomt auf das Fokusdiagramm mit
- Abschluss ($t = \text{total_dauer} - 1.2\text{s} \to \text{total_dauer}$):
- Nahtloser Übergang zurück zu genau demselben Helden-Diagramm.
- Über die letzten 1.2s hineinzoomen von
zoom = 1.0zurück zuzoom = 1.45mittelsease_in_out_cubic:t_closing_start = total_dauer - 1.2 if time_sec >= t_closing_start: zoom_t = ease_in_out_cubic((time_sec - t_closing_start) / 1.2) cur_zoom = lerp(1.0, 1.45, zoom_t) draw_anchor_state(ctx, zoom=cur_zoom)
- Ergebnis: Frame bei $t = 0.0\text{s}$ (
zoom = 1.45) und Frame bei $t = \text{total_dauer}$ (zoom = 1.45) sind pixelidentisch.
2. Die 4-teilige Hochbindungs-Skriptformel
Hochbindungs-Shorts folgen diesem strengen narrativen Bogen:
| Teil | Timing (45–55s Short) | Timing (25–30s Short) | Zweck & Ausführung |
|---|---|---|---|
| 1. Paradox-Hook | 0.0s – 4.0s | 0.0s – 5.0s | Nenne einen krassen, kontraintuitiven Kontrast oder ein unmögliches Rätsel, beginnend mit Warum / Wie / Was. Verbanne Füllwörter, Einleitungen oder Begrüßungen. |
| 2. Einschränkung / Eskalation | 4.0s – 20.0s | 5.0s – 12.0s | Eskaliere das Problem mit konkreten Zahlen, historischen Einsätzen oder Umweltgefahren. Führe vergleichende Metriken oder Status-HUD-Balken ein. |
| 3. Der geniale Wendepunkt | 20.0s – 45.0s | 12.0s – 22.0s | Das architektonische oder wissenschaftliche Geheimnis wird gelüftet. Zerlege die technischen Mechanismen mit animierten Diagrammen, Schnittansichten, Routen oder Komponentenschichten. |
| 4. Auszahlung & Nachlaufbrücke | Letzte 5.0s | Letzte 4.0s | Liefere die „Aha!“-Auszahlung und ende mit einer nachlaufenden Satzbrücke, die nahtlos in den Eröffnungs-Hook zurückloopt, ohne nachlaufende Stille. |
- Sprechgeschwindigkeit: 150–170 Wörter pro Minute (WPM).
- Zielwortanzahlen:
- 45–55s Short: 110–145 Wörter.
- 25–30s Short: 65–75 Wörter.
3. Visuelle & Layout-Regeln
- Leinwandspezifikationen:
- Auflösung:
1080x1920(9:16 vertikales Seitenverhältnis). - Bildrate:
60 FPSkonstant. - Vektorgrafik-Präzision: Alle Formen, Symbole und Linien werden über PyCairo für scharfes Anti-Aliasing gezeichnet.
- Auflösung:
- Sichere Zonen (Entscheidend für mobile Benutzeroberflächen):
- Obere sichere Zone ($y \ge 130$): Platziere Titel, Kapitel-Pills und obere Banner zwischen $y = 130$ und $y = 260$, um den Kanalnamen und obere UI-Overlays zu vermeiden.
- Zentraler Aktionsbereich ($y = 280$ bis $y = 1420$): Alle Fokusgrafiken, Schnittansichten, Kartenrouten und Statistikabzeichen befinden sich hier.
- Untertitel-Sicherheitszone unten: Untertitel werden bei $y = 1480$–$1540$ verankert. Halte Hintergrund-Fokuselemente oberhalb von $y = 1400$, um eine Überlappung mit dem Untertiteltext zu vermeiden. Vermeide wesentliche visuelle Details in den unteren 380px (wo sich Titel, Ton und Abonnieren-Buttons auf YouTube und TikTok befinden).
- Pacing-Regel (3–5 Sekunden Zustandsänderungen):
- Die Aufmerksamkeit des Betrachters lässt schnell nach. Ändere den visuellen Zustand, den Kamerafokus oder löse eine Animation alle 3 bis 5 Sekunden aus (z. B. Abzeichen-Feder-Pop, HUD-Metrik-Füllung, Pfadfortschritt oder Schichtaufdeckung).
- Exaktes wort-synchrones Sounddesign:
- Rate niemals SFX-Timings.
- Führe zuerst
generate_voiceoveraus, umwords.jsonzu erzeugen. - Überprüfe
words.jsonauf wichtige Auslösewörter (z. B. Zahlen, Aktionsverben, Szenenwechsel) und platzierewhoosh-,pop- undthud-Hinweise bei der genauenstart_secdieser Wörter.
4. Vollständige Engine-API-Referenz
Alle Module werden sauber aus engine exportiert:
from engine import (
# Renderer
VideoRenderer,
# Easing-Mathematik
clamp, lerp, ease_out_cubic, ease_in_out_cubic, spring_pop,
cubic_bezier_point, cubic_bezier_tangent, cubic_bezier_angle,
# 2D-Vektor-Primitive
draw_parchment_bg, draw_dark_bg, draw_top_banner, draw_map_route,
draw_hud_bars, draw_stat_badge, draw_rounded_rect, draw_card_shadow,
# Aktive-Wort-Karaoke-Untertitel
draw_karaoke_captions, chunk_words,
# Audio-Synthese, Mischen & Multiplexen
generate_voiceover, generate_voiceover_async,
ensure_default_sfx, get_downloads_dir, mix_final_audio, mux_video_audio,
)
VideoRenderer (engine.renderer)
class VideoRenderer:
def __init__(
self,
output_path: Union[str, Path],
width: int = 1080,
height: int = 1920,
fps: int = 60,
crf: int = 18,
preset: str = "fast",
audio_path: Optional[Union[str, Path]] = None,
ffmpeg_bin: Optional[str] = None,
) -> None: ...
def create_surface(self) -> Tuple[cairo.ImageSurface, cairo.Context]: ...
def write_surface(self, surface: cairo.ImageSurface) -> None: ...
def render(
self,
draw_fn: Callable[[cairo.Context, int, float], None],
total_frames: Optional[int] = None,
duration_sec: Optional[float] = None,
on_progress: Optional[Callable[[int, int], None]] = None,
) -> Path: ...
Easing-Hilfsfunktionen (engine.easing)
def clamp(val: float, min_val: float = 0.0, max_val: float = 1.0) -> float: ...
def lerp(a: float, b: float, t: float) -> float: ...
def ease_out_cubic(t: float) -> float: ...
def ease_in_out_cubic(t: float) -> float: ...
def spring_pop(t: float, overshoot: float = 1.2) -> float: ...
def cubic_bezier_point(p0: Point2D | float, p1: Point2D | float, p2: Point2D | float, p3: Point2D | float, t: float) -> Tuple[float, float] | float: ...
def cubic_bezier_tangent(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: float) -> Tuple[float, float]: ...
def cubic_bezier_angle(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: float) -> float: ...
Wiederverwendbare Vektor-Primitive (engine.primitives)
def draw_parchment_bg(ctx: cairo.Context, width: int = 1080, height: int = 1920, seed: int = 42) -> None: ...
def draw_dark_bg(ctx: cairo.Context, width: int = 1080, height: int = 1920) -> None: ...
def draw_top_banner(ctx: cairo.Context, title: str, subtitle: Optional[str] = None, progress: float = 1.0, theme: str = "light") -> None: ...
def draw_map_route(ctx: cairo.Context, points: Sequence[Tuple[float, float]], progress: float, color: Tuple[float, float, float] = (0.86, 0.15, 0.15), line_width: float = 8.0, dashed: bool = True, show_arrow: bool = True, pulse_time: float = 0.0) -> None: ...
def draw_hud_bars(ctx: cairo.Context, metrics: List[Dict[str, Any]], x: float, y: float, width: float = 880.0, theme: str = "light") -> None: ...
def draw_stat_badge(ctx: cairo.Context, value_text: str, label_text: str, x: float, y: float, scale: float = 1.0, accent_color: Tuple[float, float, float] = (0.98, 0.80, 0.08), theme: str = "light") -> None: ...
def draw_rounded_rect(ctx: cairo.Context, x: float, y: float, w: float, h: float, r: float) -> None: ...
def draw_card_shadow(ctx: cairo.Context, x: float, y: float, w: float, h: float, r: float, blur_layers: int = 4, offset_y: float = 6.0, base_alpha: float = 0.12) -> None: ...
Aktive-Wort-Karaoke-Untertitel (engine.captions)
def chunk_words(words: Sequence[Dict[str, Any]], max_words: int = 3, max_chars: int = 18) -> List[Dict[str, Any]]: ...
def draw_karaoke_captions(ctx: cairo.Context, words: Sequence[Dict[str, Any]], current_time: float, center_x: float = 540.0, y: float = 1480.0, font_path: Optional[Union[str, Path]] = None, font_size: int = 76, stroke_width: int = 8, active_color: Tuple[int, int, int] = (250, 204, 21), inactive_color: Tuple[int, int, int] = (255, 255, 255), active_scale: float = 1.12) -> None: ...
Voiceover, Soundeffekte & Mischen (engine.tts_align, engine.audio_mixer)
def generate_voiceover(script_text: str, output_audio_path: Union[str, Path], output_words_json: Optional[Union[str, Path]] = None, voice: str = "en-US-AndrewNeural", rate: str = "+12%") -> float: ...
def ensure_default_sfx(sfx_dir: Union[str, Path] = "assets/sfx") -> Dict[str, Path]: ...
def get_downloads_dir() -> Path: ...
def mix_final_audio(voiceover_path: Union[str, Path], sfx_events: Sequence[Tuple[float, str]], output_audio_path: Union[str, Path], total_duration: float, include_ambient: bool = True, sfx_dir: Union[str, Path] = "assets/sfx", ffmpeg_bin: Optional[str] = None) -> Path: ...
def mux_video_audio(video_path: Union[str, Path], audio_path: Union[str, Path], final_output_path: Optional[Union[str, Path]] = None, filename: Optional[str] = None, ffmpeg_bin: Optional[str] = None) -> str: ...
5. Autonomer Ausführungsworkflow
Wenn du mit der Erstellung eines Shorts zu einem beliebigen Thema beauftragt wirst, führe diese Schritte nacheinander aus:
Schritt 1: Skriptkomposition (Satzbrücken-Regel)
Schreibe ein knappes 65–140 Wörter umfassendes Skript nach der 4-teiligen Formel.
- MUSS mit einer scharfen Warum / Wie / Was-Frage beginnen.
- MUSS mit einer nachlaufenden Satzbrücke enden (
"...weshalb die Leute immer noch fragen,"). - Wähle einen klaren Themenslug (z. B.
colosseum_floododervibranium_physics).
Schritt 2: Voiceover & Zeitstempelgenerierung in scratch/
Halte output/ sauber, indem du alle Zwischendateien in scratch/ speicherst:
.venv\Scripts\python -c "from engine import generate_voiceover; generate_voiceover('''<skript>''', 'scratch/<slug>_vo.mp3', 'scratch/<slug>_words.json')"
Überprüfe scratch/<slug>_words.json, um Folgendes zu bestimmen:
- Die genaue
audio_dauer - Die Zeitstempel wichtiger Meilensteinwörter (z. B. wo der Hook endet, wo Zahlen gesprochen werden, wo der Wendepunkt und die letzte Brücke auftreten).
Schritt 3: Skriptgesteuerte Szenen-Pipeline-Erstellung
Erstelle projects/<slug>.py mit Importen aus engine:
- Setze
total_dauer = audio_dur + 0.05(Keine nachlaufende Stille, um die sofortige Schleife beizubehalten). - Schreibe Zwischenvideo-/Audiodateien nach
scratch/<slug>_silent.mp4undscratch/<slug>_audio.aac. - Definiere
draw_anchor_state(ctx, zoom, pan)für das Helden-Diagramm. - Verwende
draw_anchor_statemitzoom = 1.45 -> 1.0überease_out_cubicfür die Eröffnung $0.0\text{s} \to 1.2\text{s}$. - Verwende
draw_anchor_statemitzoom = 1.0 -> 1.45überease_in_out_cubicfür die letzten $1.2\text{s}$ ($t_{\text{close}} \to \text{total_dauer}$) und stelle so einen pixelidentischen Match-Cut mit Frame 0 sicher. - Definiere mittlere Szenenübergänge, die dem tatsächlichen Erzähltempo aus
scratch/<slug>_words.jsonentsprechen. - Ordne SFX-Ereignisse
[(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')]den genauen Wortzeitstempeln zu. - Implementiere
draw_frame(ctx, frame_idx, time_sec)und rendere die Animation inscratch/<slug>_silent.mp4.
Schritt 4: Audio mischen & multiplexen
Führe diese Befehle aus:
# Soundeffekte mit dem Voiceover mischen
.venv\Scripts\python -c "from engine import mix_final_audio; mix_final_audio('scratch/<slug>_vo.mp3', sfx_events, 'scratch/<slug>_audio.aac', total_dauer)"
# Video und Audio multiplexen
.venv\Scripts\python -c "from engine import mux_video_audio; mux_video_audio('scratch/<slug>_silent.mp4', 'scratch/<slug>_audio.aac', filename='<slug>.mp4')"
Schritt 5: Im Verzeichnis output/ ablegen
Verschiebe das endgültige gerenderte Video nach output/<slug>.mp4.
Kurzreferenz: Typischer Projektskript-Boilerplate (projects/<slug>.py)
import math
from pathlib import Path
from engine import (
VideoRenderer,
clamp, lerp, ease_out_cubic, ease_in_out_cubic, spring_pop,
draw_dark_bg, draw_top_banner, draw_hud_bars, draw_stat_badge,
draw_karaoke_captions, chunk_words,
generate_voiceover, mix_final_audio, mux_video_audio,
)
# ============================================================
# SCHRITT 1: Skript (4-teilige Formel + Satzbrücke)
# ============================================================
SCRIPT = """Warum würde ...? ... Weshalb die Leute immer noch fragen,"""
SLUG = "<slug>"
# ============================================================
# SCHRITT 2: Voiceover generieren & timestamp-ausgerichtete Metriken extrahieren
# ============================================================
audio_dur = generate_voiceover(SCRIPT, f"scratch/{SLUG}_vo.mp3", f"scratch/{SLUG}_words.json")
TOTAL_DUR = audio_dur + 0.05 # KEINE nachlaufende Stille!
# ============================================================
# SCHRITT 3: Helden-Ankerzustand (pixelidentischer Match-Cut)
# ============================================================
def draw_anchor_state(ctx, zoom=1.0, pan=(0.0, 0.0)):
ctx.save()
ctx.translate(540 + pan[0], 960 + pan[1])
ctx.scale(zoom, zoom)
ctx.translate(-540, -960)
draw_dark_bg(ctx, 1080, 1920)
draw_top_banner(ctx, "HELDEN-TITEL", "KATEGORIE", progress=1.0, theme="dark")
# ... zeichne das Hauptdiagramm / die Hauptgrafik
ctx.restore()
# ============================================================
# SCHRITT 4: Szenen-Rendering
# ============================================================
def draw_frame(ctx, frame_idx, time_sec):
# MATCH-CUT-Eröffnungszoom
if time_sec <= 1.2:
zoom_t = ease_out_cubic(time_sec / 1.2)
draw_anchor_state(ctx, zoom=lerp(1.45, 1.0, zoom_t))
return
# MATCH-CUT-Abschlusszoom
t_start = TOTAL_DUR - 1.2
if time_sec >= t_start:
zoom_t = ease_in_out_cubic((time_sec - t_start) / 1.2)
draw_anchor_state(ctx, zoom=lerp(1.0, 1.45, zoom_t))
return
# Mittlere Szenen ...
draw_anchor_state(ctx, zoom=1.0)
...
# Karaoke-Untertitel (immer aktiv)
words = load_json(f"scratch/{SLUG}_words.json")
draw_karaoke_captions(ctx, words, time_sec)
# ============================================================
# SCHRITT 5: Rendern & Multiplexen
# ============================================================
if __name__ == "__main__":
renderer = VideoRenderer(
output_path=f"scratch/{SLUG}_silent.mp4",
width=1080, height=1920, fps=60, crf=18,
)
renderer.render(draw_frame, duration_sec=TOTAL_DUR)
sfx_events = [(0.5, "whoosh"), (2.0, "pop"), (4.5, "thud")]
mix_final_audio(f"scratch/{SLUG}_vo.mp3", sfx_events, f"scratch/{SLUG}_audio.aac", TOTAL_DUR)
mux_video_audio(f"scratch/{SLUG}_silent.mp4", f"scratch/{SLUG}_audio.aac", filename=f"{SLUG}.mp4")
print(f"✅ Short gerendert: output/{SLUG}.mp4")
Typischer .gitignore
output/
scratch/
assets/sfx/*.mp3
__pycache__/
*.pyc
.venv/
Warum das funktioniert
- Loop-Mechanik: Zuschauer können nicht erkennen, wo das Video endet und wieder beginnt, was die durchschnittliche prozentuale Ansicht über 100% treibt (YouTube belohnt dies mit mehr Impressions).
- PyCairo + FFmpeg stdin: Keine temporären PNGs auf der Festplatte. Jeder Frame ist ein Vektor, skaliert auf jede Auflösung, und wird mit GPU-freundlicher Hardwarekodierung direkt per Pipe an FFmpeg gesendet.
- Wortgenaue Karaoke-Untertitel: Zuschauer lesen unbewusst mit, was die Bindung um ~30 % erhöht, selbst bei ausgeschaltetem Ton.
- Edge TTS: Natürlich klingende neuronale Stimmen („Andrew Neural“) mit anpassbarer Geschwindigkeit (z. B.
"+12%"für 150–170 Wörter pro Minute).