Communitygithub.com

Tanush-Gupta1812/py-shorts-studio

Generates, scripts, animates, and renders viral 60fps vertical 2D vector explainer videos, YouTube Shorts, and Reels using PyCairo, Edge TTS active-word karaoke captions, procedural sound effects, and direct FFmpeg pipe encoding. Enforces seamless audio and visual match-cut loops for infinite retention. Use whenever the user asks to generate, create, or render a YouTube Short, vertical explainer video, or narrated 2D motion-graphics video on any topic (history, engineering, science, economics, tech, survival, or mysteries).

What is py-shorts-studio?

py-shorts-studio is a Claude Code agent skill that generates, scripts, animates, and renders viral 60fps vertical 2D vector explainer videos, YouTube Shorts, and Reels using PyCairo, Edge TTS active-word karaoke captions, procedural sound effects, and direct FFmpeg pipe encoding. Enforces seamless audio and visual match-cut loops for infinite retention. Use whenever the user asks to generate, create, or render a YouTube Short, vertical explainer video, or narrated 2D motion-graphics video on any topic (history, engineering, science, economics, tech, survival, or mysteries).

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

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

Py-Shorts-Studio: Viral 2D Vector Explainer Engine

py-shorts-studio is a high-performance Python framework for creating vertical (1080x1920, 9:16), 60 FPS motion graphics explainer Shorts. It bypasses disk I/O by streaming PyCairo vector frames directly to FFmpeg stdin, aligns voiceovers with active-word karaoke captions, synthesizes custom SFX, and mixes multi-track audio.


1. Seamless Audio + Visual Match-Cut Loop (Infinite Retention)

To maximize Average Percentage Viewed (>100% loop retention on YouTube Shorts & Instagram Reels), every generated Short MUST enforce both an audio sentence bridge and a pixel-identical visual match-cut.

A. The Sentence-Bridge Script Rule

When YouTube loops a Short, the end of the audio connects instantaneously to the start. The script must exploit this:

  1. Opening Hook: MUST open immediately with a sharp Why / How / What question.
    • Example: "Why would Iron Man's suit kill Tony Stark in three seconds?"
    • Example: "Why do modern highways crumble in fifteen years while Roman roads last two thousand?"
  2. Ending Lead-In Phrase: The final sentence MUST end on a trailing, anticipatory lead-in phrase rather than a definitive sign-off or subscribe callout:
    • Example: "...which is why people still ask,"
    • Example: "...so the next time someone asks you,"
    • Example: "...and that explains,"
  3. Continuous Grammatical Loop: When the video loops from the trailing phrase into the opening question, it forms one seamless grammatical sentence:

    "...which is why people still ask — Why would Iron Man's suit kill Tony Stark in three seconds?"

  4. Zero Trailing Silence:
    • total_duration = audio_duration + 0.05 (NEVER add 0.4s or 0.75s of trailing dead air).
    • Any pause at the end breaks the illusion of an infinite loop. The audio loop must hit the first word of the restart with zero perceivable gap.

B. Pixel-Identical Frame-0 Match Cut

The very first frame ($t = 0.0\text{s}$) and the very last frame ($t = \text{total_duration}$) must be visually identical so the viewer cannot detect when the video repeats.

Implementation Pattern: draw_anchor_state

Every project script must define a shared draw_anchor_state(ctx, zoom=1.0, pan=(0, 0)) function containing the primary "Hero" visual and top banner.

def draw_anchor_state(ctx: cairo.Context, zoom: float = 1.0, pan: tuple[float, float] = (0.0, 0.0)) -> None:
    """Renders the master hero diagram and top banner at an arbitrary zoom/pan center.

    Used by both Scene 1 (opening ease-out zoom) and the final scene (closing ease-in zoom)
    to guarantee a pixel-identical match cut across the loop boundary.
    """
    ctx.save()
    # Center-origin transform (1080x1920 center is 540, 960)
    ctx.translate(540.0 + pan[0], 960.0 + pan[1])
    ctx.scale(zoom, zoom)
    ctx.translate(-540.0, -960.0)

    # 1. Background
    draw_dark_bg(ctx, 1080, 1920)

    # 2. Top Banner
    draw_top_banner(ctx, "HERO TOPIC TITLE", "ENGINEERING EXPLAINER", progress=1.0, theme="dark")

    # 3. Hero Visual / Diagram in Action Zone
    # (Draw central diagram, cross-section, or apparatus here)
    ...
    ctx.restore()

Match-Cut Zoom Dynamics (0.0s – 1.2s & Last 1.2s)

  • Opening ($t = 0.0\text{s} \to 1.2\text{s}$):
    • Starts at $t = 0.0\text{s}$ tightly zoomed in on the focal diagram at zoom = 1.45.
    • Over $0.0\text{s} \to 1.2\text{s}$, smoothly eases out to wide view (zoom = 1.0) using 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)
      
  • Closing ($t = \text{total_duration} - 1.2\text{s} \to \text{total_duration}$):
    • Smoothly transitions back to that exact same hero diagram.
    • Over the final 1.2s, zooms in from zoom = 1.0 back to zoom = 1.45 using ease_in_out_cubic:
      t_closing_start = total_duration - 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)
      
  • Result: Frame at $t = 0.0\text{s}$ (zoom = 1.45) and frame at $t = \text{total_duration}$ (zoom = 1.45) are pixel-identical.

2. The 4-Part High-Retention Script Formula

High-retention Shorts follow this strict narrative arc:

PartTiming (45–55s Short)Timing (25–30s Short)Purpose & Execution
1. Paradox Hook0.0s – 4.0s0.0s – 5.0sState a stark, counter-intuitive contrast or impossible mystery starting with Why / How / What. Ban fluff, intros, or greetings.
2. Constraint / Escalation4.0s – 20.0s5.0s – 12.0sEscalate the problem with concrete numbers, historical stakes, or environmental hazards. Introduce comparative metrics or status HUD bars.
3. The Ingenious Pivot20.0s – 45.0s12.0s – 22.0sThe architectural or scientific secret revealed. Break down the engineering mechanics using animated diagrams, cutaways, routes, or component layers.
4. Payoff & Trailing BridgeLast 5.0sLast 4.0sDeliver the "aha!" payoff and end with a trailing sentence bridge that loops back into the opening hook with zero trailing silence.
  • Speaking Pacing: 150–170 words per minute (WPM).
  • Target Word Counts:
    • 45–55s Short: 110–145 words.
    • 25–30s Short: 65–75 words.

3. Visual & Layout Rules

  1. Canvas Specifications:
    • Resolution: 1080x1920 (9:16 vertical aspect ratio).
    • Frame Rate: 60 FPS constant.
    • Vector Precision: All shapes, icons, and lines drawn via PyCairo for crisp anti-aliasing.
  2. Safe Zones (Crucial for Mobile UI):
    • Top Safe Zone ($y \ge 130$): Place titles, chapter pills, and top banners between $y = 130$ and $y = 260$ to avoid the channel handle and top UI overlays.
    • Center Action Zone ($y = 280$ to $y = 1420$): All focal graphics, cutaways, map routes, and stat badges live here.
    • Bottom Caption Safe Zone: Captions are anchored at $y = 1480$–$1540$. Keep background focal elements above $y = 1400$ to prevent overlapping caption text. Avoid essential visual details in the bottom 380px (where title, sound, and subscribe buttons reside on YouTube and TikTok).
  3. Pacing Rule (3–5 Second State Changes):
    • Viewer attention decays rapidly. Change the visual state, camera focus, or trigger an animation every 3 to 5 seconds (e.g. badge spring pop, HUD metric fill, path progression, or layer reveal).
  4. Exact Word-Synced Sound Design:
    • Never guess SFX timings.
    • Run generate_voiceover first to produce words.json.
    • Inspect words.json for key trigger words (e.g. numbers, action verbs, scene shifts) and place whoosh, pop, and thud cues at the exact start_sec of those words.

4. Complete Engine API Reference

All modules are cleanly exported from engine:

from engine import (
    # Renderer
    VideoRenderer,
    # Easing Mathematics
    clamp, lerp, ease_out_cubic, ease_in_out_cubic, spring_pop,
    cubic_bezier_point, cubic_bezier_tangent, cubic_bezier_angle,
    # 2D Vector Primitives
    draw_parchment_bg, draw_dark_bg, draw_top_banner, draw_map_route,
    draw_hud_bars, draw_stat_badge, draw_rounded_rect, draw_card_shadow,
    # Active-Word Karaoke Captions
    draw_karaoke_captions, chunk_words,
    # Audio Synthesis, Mixing & Muxing
    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 Helpers (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: ...

Reusable Vector Primitives (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: ...

Active-Word Karaoke Captions (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, Sound Effects & Mixing (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. Autonomous Execution Workflow

When tasked with generating a Short on any topic, execute these steps sequentially:

Step 1: Script Composition (Sentence-Bridge Rule)

Write a tight 65–140 word script following the 4-part formula.

  • MUST begin with a sharp Why / How / What question.
  • MUST end with a trailing sentence bridge ("...which is why people still ask,").
  • Choose a clear topic slug (e.g. colosseum_flood or vibranium_physics).

Step 2: Voiceover & Timestamp Generation in scratch/

Keep output/ clean by storing all intermediate files in scratch/:

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

Inspect scratch/<slug>_words.json to determine:

  • Exact audio_duration
  • Key milestone word timestamps (e.g. where the hook ends, where numbers are spoken, where the pivot and final bridge occur).

Step 3: Scripted Scene Pipeline Generation

Create projects/<slug>.py importing from engine:

  • Set total_duration = audio_dur + 0.05 (Zero trailing silence to maintain the instantaneous loop).
  • Write intermediate video/audio files to scratch/<slug>_silent.mp4 and scratch/<slug>_audio.aac.
  • Define draw_anchor_state(ctx, zoom, pan) for the hero diagram.
  • Use draw_anchor_state with zoom = 1.45 -> 1.0 via ease_out_cubic for the opening $0.0\text{s} \to 1.2\text{s}$.
  • Use draw_anchor_state with zoom = 1.0 -> 1.45 via ease_in_out_cubic for the final $1.2\text{s}$ ($t_{\text{close}} \to \text{total_duration}$), ensuring a pixel-identical match cut with frame 0.
  • Define middle scene transitions matching the actual narration pacing from scratch/<slug>_words.json.
  • Map SFX events [(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')] to the exact word timestamps.
  • Implement draw_frame(ctx, frame_idx, time_sec) rendering dynamic vector visuals + draw_karaoke_captions(ctx, words, time_sec).
  • Stream frames to scratch/<slug>_silent.mp4 via VideoRenderer.
  • Mix audio with mix_final_audio $\to$ scratch/<slug>_audio.aac.
  • Call mux_video_audio(silent_video, audio_track, filename="<slug>.mp4"). This automatically saves the finished video directly into the user's system Downloads folder on any OS and returns the absolute path string.
  • Delete all temporary files from scratch/ (<slug>_vo.mp3, <slug>_words.json, <slug>_silent.mp4, <slug>_audio.aac) so no clutter remains.

Step 4: Execution & Verification

Run the project script:

.venv\Scripts\python projects/<slug>.py

Verify the output in Downloads with ffprobe:

ffprobe -v error -show_entries stream=width,height,r_frame_rate,duration -of default=noprint_wrappers=1 "$HOME/Downloads/<slug>.mp4"

Ensure video is 1080x1920, 60fps, with crisp audio, clean active-word gold karaoke captions, zero trailing silence, and a pixel-identical visual match cut across the loop boundary.

Step 5: Generate Upload Pack & Save <topic_slug>_upload.txt

Save a companion file Downloads/<slug>_upload.txt alongside the video containing the title, description, tags, and pinned comment. Always conclude the final assistant response with the complete, copy-paste-ready Upload Pack.


6. YouTube Shorts Upload Pack Standard

Whenever a Short is generated, the agent MUST ALWAYS save Downloads/<topic_slug>_upload.txt and present the complete, copy-paste-ready Upload Pack at the end of the response:

### 🚀 YouTube Shorts Upload Pack

- **Saved Video File**: `<Full Path to Downloads/<topic_slug>.mp4>`
- **Upload Metadata File**: `<Full Path to Downloads/<topic_slug>_upload.txt>`

#### 📌 Viral Video Title (<= 60 chars)
[Curiosity-driven hook title with 1–2 relevant emojis and #shorts at the end]
Example: Why Roman Concrete Still Heals Itself 🏛️⚡ #shorts

#### 📝 SEO Video Description
[2–3 punchy sentences summarizing the core mystery, scientific mechanism, and key takeaway for search indexing, followed by 8–10 relevant hashtags]

Example:
Modern highways crumble after fifteen years, but ancient Roman roads are still standing strong after two thousand. The secret isn't just heavy stones—it's active volcanic pozzolana ash that chemically recrystallizes and self-heals cracks whenever rainwater seeps in. Here is the genius four-layer engineering fortress behind the Roman Empire's immortal infrastructure.

#shorts #youtubeshorts #romanempire #ancientengineering #historyfacts #scienceexplained #civilengineering #stem #education

#### 🏷️ Copy-Paste Hashtags Line
#shorts #youtubeshorts #[tag3] #[tag4] #[tag5] #[tag6] #[tag7] #[tag8] #[tag9] #[tag10]

#### 💬 Pinned Comment Prompt
[1 polarizing or debate-sparking question related to the video to drive comments, debate, and watch-time loops]
Example: "If Rome had this self-healing concrete 2,000 years ago, why aren't modern roads using it today? Drop your theory below 👇"

Related Skills