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 :
- 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 ?"
- 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,"
- 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 ?"
- Zéro silence de fin :
durée_totale = durée_audio + 0.05(NE JAMAIS ajouter0.4sou0.75sde 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 utilisantease_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)
- Commence à $t = 0.0\text{s}$ en zoomant fortement sur le diagramme focal à
- 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.45en utilisantease_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 :
| Partie | Timing (Short 45–55s) | Timing (Short 25–30s) | Objectif et Exécution |
|---|---|---|---|
| 1. Accroche Paradoxale | 0.0s – 4.0s | 0.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 / Escalade | 4.0s – 20.0s | 5.0s – 12.0s | Intensifier 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énieux | 20.0s – 45.0s | 12.0s – 22.0s | Le 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 Transition | Dernières 5.0s | Dernières 4.0s | Apporter 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
- Spécifications du canevas :
- Résolution :
1080x1920(format vertical 9:16). - Fréquence d'images :
60 FPSconstante. - Précision vectorielle : Toutes les formes, icônes et lignes dessinées via PyCairo pour un anti-aliasing net.
- Résolution :
- 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).
- 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).
- Conception sonore synchronisée au mot près :
- Ne jamais deviner les timings des effets sonores.
- Exécuter d'abord
generate_voiceoverpour produiremots.json. - Inspecter
mots.jsonpour les mots déclencheurs clés (par exemple, nombres, verbes d'action, changements de scène) et placer les signauxwhoosh,popetthudà lastart_secexacte 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_inondationouvibranium_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_audioexacte - 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.mp4etscratch/<slug>_audio.aac. - Définissez
draw_anchor_state(ctx, zoom, pan)pour le diagramme héros. - Utilisez
draw_anchor_stateaveczoom = 1.45 -> 1.0viaease_out_cubicpour l'ouverture $0.0\text{s} \to 1.2\text{s}$. - Utilisez
draw_anchor_stateaveczoom = 1.0 -> 1.45viaease_in_out_cubicpour 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
- Rendre la vidéo silencieuse :
.venv\Scripts\python -c "from projects.<slug> import render; render()" - 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)" - 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')" - 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 exemple31.05spour une voix off de31.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).
- Lire la durée :
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