Communitygithub.com

py-shorts-studio

Génère, scénarise, anime et rend des vidéos explicatives verticales vectorielles 2D virales à 60fps, des YouTube Shorts et des Reels, en utilisant PyCairo, des sous-titres karaoké actifs par mot Edge TTS, des effets sonores procéduraux et un encodage direct via pipe FFmpeg. Boucle audio et visuelle parfaite pour une rétention infinie.

Qu'est-ce que py-shorts-studio ?

Py-Shorts-Studio est un framework Python haute performance conçu pour créer des YouTube Shorts et Reels explicatifs en motion design 2D, au format vertical (1080x1920, 9:16) à 60 images par seconde. Il se distingue par sa capacité à générer des boucles audio et visuelles parfaites (match cut) pour maximiser la rétention spectateur au-delà de 100%. Le framework intègre la synthèse vocale Edge TTS avec sous-titres karaoké actifs, des effets sonores procéduraux, et un pipeline de rendu direct PyCairo vers FFmpeg, éliminant les écritures disque intermédiaires. Il est particulièrement utile pour les créateurs de contenu, les marketeurs vidéo et les développeurs souhaitant automatiser la production de vidéos courtes et virales sur des sujets variés (histoire, sciences, ingénierie, économie, tech, survie, mystères). L'outil impose une structure narrative en 4 parties (accroche paradoxale, escalade, pivot ingénieux, conclusion-pont) et des règles strictes de timing et de zones de sécurité visuelle pour une adaptation parfaite aux interfaces mobiles de YouTube, Instagram et TikTok. Son workflow est entièrement scriptable et exécutable en ligne de commande, offrant un contrôle total sur chaque étape de production.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Tanush-Gupta1812/py-shorts-studio/tree/HEAD/.agents/skills/py-shorts-studio

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Py-Shorts-Studio : Moteur de Vidéos Explicatives Vectorielles 2D Virales

py-shorts-studio est un framework Python haute performance pour créer des Shorts explicatives en motion design vertical (1080x1920, 9:16) à 60 FPS. Il contourne les entrées/sorties disque en diffusant les images vectorielles PyCairo directement vers l'entrée standard de FFmpeg, aligne les voix off avec des sous-titres karaoké actifs par mot, synthétise des effets sonores personnalisés et mixe l'audio multi-pistes.


1. Boucle Audio et Visuelle Parfaite (Rétention Infinie)

Pour maximiser le pourcentage de visionnage moyen (>100% de rétention en boucle sur YouTube Shorts et Instagram Reels), chaque Short générée DOIT imposer à la fois un pont de phrase audio et un raccord visuel pixel-identique.

A. La Règle du Pont de Phrase (Sentence-Bridge)

Lorsque YouTube boucle une Short, la fin de l'audio se connecte instantanément au début. Le script doit exploiter cela :

  1. Accroche d'ouverture : DOIT commencer immédiatement par une question Pourquoi / Comment / Quoi incisive.
    • Exemple : "Pourquoi le costume d'Iron Man tuerait-il Tony Stark en trois secondes ?"
    • Exemple : "Pourquoi les autoroutes modernes s'effondrent-elles en quinze ans alors que les routes romaines durent deux mille ans ?"
  2. Phrase de transition finale : La dernière phrase DOIT se terminer par une phrase d'introduction anticipative plutôt qu'une formule de conclusion ou d'appel à l'abonnement :
    • Exemple : "...c'est pourquoi les gens demandent encore,"
    • Exemple : "...alors la prochaine fois que quelqu'un vous demandera,"
    • Exemple : "...et cela explique,"
  3. Boucle grammaticale continue : Lorsque la vidéo boucle de la phrase de transition vers la question d'ouverture, elle forme une phrase grammaticale continue :

    "...c'est pourquoi les gens demandent encore — Pourquoi le costume d'Iron Man tuerait-il Tony Stark en trois secondes ?"

  4. Zéro silence de fin :
    • durée_totale = durée_audio + 0.05 (NE JAMAIS ajouter 0.4s ou 0.75s de vide sonore en fin).
    • Toute pause à la fin brise l'illusion de la boucle infinie. La boucle audio doit atteindre le premier mot du redémarrage sans aucun silence perceptible.

B. Raccord Visuel Pixel-Identique (Frame-0 Match Cut)

La toute première image ($t = 0.0\text{s}$) et la toute dernière image ($t = \text{durée_totale}$) doivent être visuellement identiques afin que le spectateur ne puisse pas détecter quand la vidéo se répète.

Modèle d'implémentation : draw_anchor_state

Chaque script de projet doit définir une fonction partagée draw_anchor_state(ctx, zoom=1.0, pan=(0, 0)) contenant le visuel « Héros » principal et la bannière supérieure.

def draw_anchor_state(ctx: cairo.Context, zoom: float = 1.0, pan: tuple[float, float] = (0.0, 0.0)) -> None:
    """Rend le diagramme héros principal et la bannière supérieure à un zoom/pan central arbitraire.

    Utilisé à la fois par la scène 1 (zoom out d'ouverture) et la scène finale (zoom in de fermeture)
    pour garantir un raccord pixel-identique à travers la limite de boucle.
    """
    ctx.save()
    # Transformation centrée sur l'origine (le centre de 1080x1920 est 540, 960)
    ctx.translate(540.0 + pan[0], 960.0 + pan[1])
    ctx.scale(zoom, zoom)
    ctx.translate(-540.0, -960.0)

    # 1. Arrière-plan
    draw_dark_bg(ctx, 1080, 1920)

    # 2. Bannière supérieure
    draw_top_banner(ctx, "TITRE DU SUJET HÉROS", "EXPLICATIF INGÉNIERIE", progress=1.0, theme="dark")

    # 3. Visuel Héros / Diagramme dans la zone d'action
    # (Dessiner le diagramme central, la coupe transversale ou l'appareil ici)
    ...
    ctx.restore()

Dynamiques de zoom pour le raccord (0.0s – 1.2s & Dernières 1.2s)

  • Ouverture ($t = 0.0\text{s} \to 1.2\text{s}$) :
    • Commence à $t = 0.0\text{s}$ en zoomant fortement sur le diagramme focal à zoom = 1.45.
    • Sur $0.0\text{s} \to 1.2\text{s}$, dézoomme progressivement vers la vue large (zoom = 1.0) en utilisant ease_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)
      
  • Fermeture ($t = \text{durée_totale} - 1.2\text{s} \to \text{durée_totale}$) :
    • Transitionne progressivement vers le même diagramme héros.
    • Sur les 1.2s finales, zoome de zoom = 1.0 à zoom = 1.45 en utilisant ease_in_out_cubic :
      t_debut_fermeture = duree_totale - 1.2
      if time_sec >= t_debut_fermeture:
          zoom_t = ease_in_out_cubic((time_sec - t_debut_fermeture) / 1.2)
          cur_zoom = lerp(1.0, 1.45, zoom_t)
          draw_anchor_state(ctx, zoom=cur_zoom)
      
  • Résultat : L'image à $t = 0.0\text{s}$ (zoom = 1.45) et l'image à $t = \text{durée_totale}$ (zoom = 1.45) sont pixel-identiques.

2. La Formule de Script en 4 Parties pour une Haute Rétention

Les Shorts à haute rétention suivent cet arc narratif strict :

PartieTiming (Short 45–55s)Timing (Short 25–30s)Objectif et Exécution
1. Accroche Paradoxale0.0s – 4.0s0.0s – 5.0sÉnoncer un contraste frappant et contre-intuitif ou un mystère impossible, en commençant par Pourquoi / Comment / Quoi. Interdire le blabla, les intros ou les salutations.
2. Contrainte / Escalade4.0s – 20.0s5.0s – 12.0sIntensifier le problème avec des chiffres concrets, des enjeux historiques ou des dangers environnementaux. Introduire des métriques comparatives ou des barres de statut HUD.
3. Le Pivot Ingénieux20.0s – 45.0s12.0s – 22.0sLe secret architectural ou scientifique dévoilé. Décomposer les mécanismes d'ingénierie à l'aide de diagrammes animés, de coupes, de tracés ou de couches de composants.
4. Conclusion et Pont de TransitionDernières 5.0sDernières 4.0sApporter la révélation « ah ! » et terminer par une phrase de transition qui boucle vers l'accroche d'ouverture, sans aucun silence de fin.
  • Rythme d'élocution : 150–170 mots par minute (MPM).
  • Comptes de mots cibles :
    • Short 45–55s : 110–145 mots.
    • Short 25–30s : 65–75 mots.

3. Règles Visuelles et de Disposition

  1. Spécifications du canevas :
    • Résolution : 1080x1920 (format vertical 9:16).
    • Fréquence d'images : 60 FPS constante.
    • Précision vectorielle : Toutes les formes, icônes et lignes dessinées via PyCairo pour un anti-aliasing net.
  2. Zones de sécurité (Crucial pour l'interface mobile) :
    • Zone de sécurité supérieure ($y \ge 130$) : Placer les titres, les pastilles de chapitre et les bannières supérieures entre $y = 130$ et $y = 260$ pour éviter le nom de la chaîne et les superpositions d'interface en haut.
    • Zone d'action centrale ($y = 280$ à $y = 1420$) : Tous les graphismes focaux, les coupes, les itinéraires cartographiques et les badges de statistiques se trouvent ici.
    • Zone de sécurité des sous-titres inférieure : Les sous-titres sont ancrés à $y = 1480$–$1540$. Maintenir les éléments focaux de fond au-dessus de $y = 1400$ pour éviter le chevauchement du texte des sous-titres. Éviter les détails visuels essentiels dans les 380px inférieurs (où se trouvent le titre, le son et les boutons d'abonnement sur YouTube et TikTok).
  3. Règle de rythme (changements d'état toutes les 3 à 5 secondes) :
    • L'attention du spectateur diminue rapidement. Changer l'état visuel, le focus de la caméra ou déclencher une animation toutes les 3 à 5 secondes (par exemple, apparition élastique d'un badge, remplissage d'une métrique HUD, progression d'un chemin, ou révélation d'une couche).
  4. Conception sonore synchronisée au mot près :
    • Ne jamais deviner les timings des effets sonores.
    • Exécuter d'abord generate_voiceover pour produire mots.json.
    • Inspecter mots.json pour les mots déclencheurs clés (par exemple, nombres, verbes d'action, changements de scène) et placer les signaux whoosh, pop et thud à la start_sec exacte de ces mots.

4. Référence Complète de l'API du Moteur

Tous les modules sont proprement exportés depuis engine :

from engine import (
    # Rendu
    VideoRenderer,
    # Mathématiques d'assouplissement
    clamp, lerp, ease_out_cubic, ease_in_out_cubic, spring_pop,
    cubic_bezier_point, cubic_bezier_tangent, cubic_bezier_angle,
    # Primitives vectorielles 2D
    draw_parchment_bg, draw_dark_bg, draw_top_banner, draw_map_route,
    draw_hud_bars, draw_stat_badge, draw_rounded_rect, draw_card_shadow,
    # Sous-titres karaoké actifs
    draw_karaoke_captions, chunk_words,
    # Synthèse, mixage et multiplexage audio
    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: ...

Aides d'assouplissement (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: ...

Primitives vectorielles réutilisables (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: ...

Sous-titres karaoké actifs (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: ...

Voix off, effets sonores et mixage (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. Workflow d'Exécution Autonome

Lorsqu'on vous demande de générer une Short sur un sujet quelconque, exécutez ces étapes séquentiellement :

Étape 1 : Composition du script (Règle du pont de phrase)

Rédigez un script serré de 65 à 140 mots suivant la formule en 4 parties.

  • DOIT commencer par une question incisive Pourquoi / Comment / Quoi.
  • DOIT se terminer par une phrase de transition ("...c'est pourquoi les gens demandent encore,").
  • Choisissez un slug de sujet clair (par exemple colisee_inondation ou vibranium_physique).

Étape 2 : Génération de la voix off et des horodatages dans scratch/

Gardez output/ propre en stockant tous les fichiers intermédiaires dans scratch/ :

.venv\Scripts\python -c "from engine import generate_voiceover; generate_voiceover('''<script>''', 'scratch/<slug>_vo.mp3', 'scratch/<slug>_words.json')"

Inspectez scratch/<slug>_words.json pour déterminer :

  • La durée_audio exacte
  • Les horodatages des mots clés (par exemple, où se termine l'accroche, où les chiffres sont prononcés, où se produisent le pivot et le pont final).

Étape 3 : Génération du pipeline de scènes scriptées

Créez projects/<slug>.py en important depuis engine :

  • Définissez durée_totale = durée_audio + 0.05 (Zéro silence de fin pour maintenir la boucle instantanée).
  • Écrivez les fichiers vidéo/audio intermédiaires vers scratch/<slug>_silent.mp4 et scratch/<slug>_audio.aac.
  • Définissez draw_anchor_state(ctx, zoom, pan) pour le diagramme héros.
  • Utilisez draw_anchor_state avec zoom = 1.45 -> 1.0 via ease_out_cubic pour l'ouverture $0.0\text{s} \to 1.2\text{s}$.
  • Utilisez draw_anchor_state avec zoom = 1.0 -> 1.45 via ease_in_out_cubic pour les $1.2\text{s}$ finales ($t_{\text{fermeture}} \to \text{durée_totale}$), garantissant un raccord pixel-identique avec l'image 0.
  • Définissez les transitions de scène intermédiaires correspondant au rythme réel de la narration à partir de scratch/<slug>_words.json.
  • Mappez les événements SFX [(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')] sur les horodatages exacts des mots.
  • Implémentez draw_frame(ctx, frame_idx, time_sec) en rendant les images avec les transitions appropriées.
  • Utilisez draw_karaoke_captions(ctx, words, current_time, center_x=540, y=1480) pour les sous-titres actifs.

Étape 4 : Rendu, mixage et sortie finale

  1. Rendre la vidéo silencieuse :
    .venv\Scripts\python -c "from projects.<slug> import render; render()"
    
  2. Mixer l'audio final avec les effets sonores :
    .venv\Scripts\python -c "from engine import mix_final_audio; mix_final_audio('scratch/<slug>_vo.mp3', [(t1, 'whoosh'), (t2, 'pop')], 'scratch/<slug>_audio.aac', duree_totale=duree_totale)"
    
  3. Multiplexer la vidéo et l'audio :
    .venv\Scripts\python -c "from engine import mux_video_audio; path = mux_video_audio('scratch/<slug>_silent.mp4', 'scratch/<slug>_audio.aac', filename='<slug>.mp4')"
    
  4. Vérifier le fichier final :
    • Lire la durée : ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 output/<slug>.mp4
    • La durée doit être durée_totale, par exemple 31.05s pour une voix off de 31.00s.
    • Vérifier manuellement que la 1ère image et la dernière image sont visuellement identiques (aucun flash ou changement de composition à la limite de boucle).

6. Exemple de Projet

Projet : projects/colisee_inondation.py

import math
from typing import Any, Sequence
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,
)

# Variables globales
WORDS: Sequence[dict] = []
TOTAL_DURATION: float = 0.0

def draw_anchor_state(ctx: cairo.Context, zoom: float = 1.0, pan: tuple[float, float] = (0.0, 0.0)) -> None:
    ctx.save()
    ctx.translate(540.0 + pan[0], 960.0 + pan[1])
    ctx.scale(zoom, zoom)
    ctx.translate(-540.0, -960.0)
    draw_dark_bg(ctx, 1080, 1920)
    draw_top_banner(ctx, "LE COLISÉE : INONDATION NAVALE", "INGÉNIERIE ROMAINE", progress=1.0, theme="dark")
    # ... (diagramme héros du Colisée)
    ctx.restore()

def draw_frame(ctx: cairo.Context, frame_idx: int, time_sec: float) -> None:
    global WORDS, TOTAL_DURATION
    # 1. Raccord d'ouverture : zoom avant serré -> vue large
    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)
    # 2. Raccord de fermeture : vue large -> zoom avant serré
    elif time_sec >= TOTAL_DURATION - 1.2:
        zoom_t = ease_in_out_cubic((time_sec - (TOTAL_DURATION - 1.2)) / 1.2)
        cur_zoom = lerp(1.0, 1.45, zoom_t)
        draw_anchor_state(ctx, zoom=cur_zoom)
    else:
        draw_anchor_state(ctx, zoom=1.0)
    # ... (dessiner les transitions de scène, les badges, les barres)
    draw_karaoke_captions(ctx, WORDS, time_sec, center_x=540.0, y=1480.0)

def render() -> None:
    global WORDS, TOTAL_DURATION
    # Charger les mots et la durée
    import json
    with open("scratch/colisee_inondation_words.json", "r") as f:
        WORDS = json.load(f)
    TOTAL_DURATION = WORDS[-1]["end_sec"] + 0.05 if WORDS else 0.0
    if not TOTAL_DURATION:
        # Valeur de secours
        TOTAL_DURATION = 31.05
    renderer = VideoRenderer(output_path="scratch/colisee_inondation_silent.mp4")
    result = renderer.render(
        draw_fn=draw_frame,
        duration_sec=TOTAL_DURATION,
        on_progress=lambda cur, total: print(f"Rendu image {cur}/{total}"),
    )

7. Installation et Dépendances

# Créer l'environnement virtuel
python -m venv .venv
.venv\Scripts\activate

# Installer le paquet en mode développement
pip install -e .

# Vérifier les dépendances (PyCairo, FFmpeg)
python -c "import cairo; print('PyCairo OK')"
ffmpeg -version

8. Structure des Répertoires

py-shorts-studio/
├── engine/
│   ├── __init__.py
│   ├── renderer.py
│   ├── easing.py
│   ├── primitives.py
│   ├── captions.py
│   ├── tts_align.py
│   └── audio_mixer.py
├── projects/
│   └── colisee_inondation.py
├── scratch/
├── output/
├── assets/
│   └── sfx/
│       ├── whoosh.mp3
│       ├── pop.mp3
│       └── thud.mp3
├── pyproject.toml
└── README.md

Skills associés