Communitygithub.com

Py-Shorts-Studio: バイラル2Dベクター解説動画エンジン

PyCairoとEdge TTS、FFmpegパイプエンコードを使用して、バイラルな60fps縦型2Dベクター解説動画、YouTube Shorts、Reelsを生成、スクリプト作成、アニメーション、レンダリングします。アクティブワードカラオケキャプション、プロシージャル効果音、シームレスなオーディオ&ビジュアルマッチカットループを備え、無限リテンションを実現します。

Py-Shorts-Studio: バイラル2Dベクター解説動画エンジン とは?

Py-Shorts-Studioは、Pythonベースの高性能フレームワークで、縦型(1080x1920、9:16)60FPSのモーショングラフィックス解説Shortsを生成します。主な特徴として、PyCairoベクターフレームをFFmpeg stdinに直接ストリーミングしてディスクI/Oを回避し、Edge TTSによるアクティブワードカラオケキャプションと音声を同期し、カスタム効果音を合成し、マルチトラックオーディオをミキシングします。特に、ループ再生時に視聴者に違和感を与えないよう、オーディオの末尾と先頭がシームレスにつながるセンテンスブリッジスクリプトと、最初と最後のフレームがピクセル単位で同一のビジュアルマッチカットを実装しています。このフレームワークは、高いリテンションを実現する4部構成のスクリプト構造を採用し、安全なモバイル表示領域、3~5秒ごとの状態変更、正確な単語同期サウンドデザインなどの視覚的・レイアウトルールを定義しています。ユーザーがYouTube Shortや解説動画の生成を依頼した場合、このスキルはまずスクリプトを作成し、音声を生成してタイムスタンプを抽出し、定義されたAPIを使用してシーンパイプラインを実行し、最終的に動画と音声を結合して出力します。対応プラットフォームはClaude Code, Cursor, CodexなどのAIエージェント環境です。

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

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Py-Shorts-Studio: バイラル2Dベクター解説動画エンジン は何をしますか?

py-shorts-studio は、縦型(1080x1920、9:16)60 FPS のモーショングラフィックス解説 Shorts を作成するための高性能 Python フレームワークです。PyCairo ベクターフレームを FFmpeg stdin に直接ストリーミングすることでディスク I/O を回避し、ボイスオーバーをアクティブワードカラオケキャプションと同期させ、カスタム SFX を合成し、マルチトラックオーディオをミキシングします。


1. シームレスなオーディオ + ビジュアル マッチカット ループ(無限リテンション)

平均視聴率(YouTube Shorts と Instagram Reels での 100% 以上のループリテンション)を最大化するために、生成されるすべての Short は、オーディオのセンテンスブリッジとピクセル単位で同一のビジュアルマッチカットの両方を適用しなければなりません。

A. センテンスブリッジスクリプトルール

YouTube が Short をループするとき、オーディオの終わりは瞬時に始まりに接続されます。スクリプトはこれを活用しなければなりません:

  1. オープニングフック:直ちに鋭い なぜ / どのように / 何 の質問で始めなければなりません。
    • 例:「なぜアイアンマンのスーツはトニー・スタークを3秒で殺してしまうのか?」
    • 例:「なぜ現代の高速道路は15年で崩壊するのに、ローマの道は2000年も持つのか?」
  2. エンディングリードインフレーズ:最後の文は、明確な締めくくりやチャンネル登録の呼びかけではなく、予感させるリードインフレーズで終わらなければなりません:
    • 例:「...だからこそ、今でも人々はこう尋ねるのだ、」
    • 例:「...だから次に誰かがあなたに尋ねたら、」
    • 例:「...そしてそれが説明している、」
  3. 連続する文法的ループ:ビデオがリードインフレーズからオープニング質問にループするとき、それは一つのシームレスな文を形成します:

    「...だからこそ、今でも人々はこう尋ねるのだ — なぜアイアンマンのスーツはトニー・スタークを3秒で殺してしまうのか?」

  4. ゼロテーリングサイレンス:
    • total_duration = audio_duration + 0.05 (決して 0.4s や 0.75s の無音を追加しないでください)。
    • 最後のポーズは無限ループの錯覚を壊します。オーディオループは、知覚可能なギャップゼロで再スタートの最初の単語にヒットしなければなりません。

B. ピクセル単位で同一のフレーム0マッチカット

最初のフレーム($t = 0.0\text{s}$)と最後のフレーム($t = \text{total_duration}$)は視覚的に同一でなければならず、視聴者はビデオが繰り返されていることを検出できません。

実装パターン: draw_anchor_state

すべてのプロジェクトスクリプトは、主要な「ヒーロー」ビジュアルとトップバナーを含む共有の draw_anchor_state(ctx, zoom=1.0, pan=(0, 0)) 関数を定義しなければなりません。

def draw_anchor_state(ctx: cairo.Context, zoom: float = 1.0, pan: tuple[float, float] = (0.0, 0.0)) -> None:
    """任意のズーム/パンセンターでマスターヒーロー図とトップバナーをレンダリングします。

    シーン1(オープニングイーズアウトズーム)と最終シーン(クロージングイーズインズーム)の両方で使用され、
    ループ境界全体にわたってピクセル単位で同一のマッチカットを保証します。
    """
    ctx.save()
    # センター起点変換(1080x1920の中心は540, 960)
    ctx.translate(540.0 + pan[0], 960.0 + pan[1])
    ctx.scale(zoom, zoom)
    ctx.translate(-540.0, -960.0)

    # 1. 背景
    draw_dark_bg(ctx, 1080, 1920)

    # 2. トップバナー
    draw_top_banner(ctx, "HERO TOPIC TITLE", "ENGINEERING EXPLAINER", progress=1.0, theme="dark")

    # 3. アクションゾーンのヒーロービジュアル/図
    # (ここに中央図、断面図、または装置を描画)
    ...
    ctx.restore()

マッチカットズームダイナミクス(0.0s – 1.2s & 最後の1.2s)

  • オープニング($t = 0.0\text{s} \to 1.2\text{s}$):
    • $t = 0.0\text{s}$ で、焦点図に zoom = 1.45 でタイトにズームインして開始します。
    • $0.0\text{s} \to 1.2\text{s}$ の間、ease_out_cubic を使用してワイドビュー(zoom = 1.0)にスムーズにイーズアウトします:
      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)
      
  • クロージング($t = \text{total_duration} - 1.2\text{s} \to \text{total_duration}$):
    • そのまったく同じヒーロー図にスムーズに遷移します。
    • 最後の1.2秒間で、ease_in_out_cubic を使用して zoom = 1.0 から zoom = 1.45 にズームインします:
      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)
      
  • 結果:$t = 0.0\text{s}$(zoom = 1.45)のフレームと $t = \text{total_duration}$(zoom = 1.45)のフレームはピクセル単位で同一です。

2. 高リテンションのための4部構成スクリプト公式

高リテンション Shorts は、この厳格なナラティブアークに従います:

パートタイミング(45~55秒 Short)タイミング(25~30秒 Short)目的と実行
1. パラドックスフック0.0s – 4.0s0.0s – 5.0sなぜ / どのように / 何 で始まる、 stark な反直感的なコントラストや不可能な謎を述べます。無駄な前置き、イントロ、挨拶は禁止。
2. 制約 / エスカレーション4.0s – 20.0s5.0s – 12.0s具体的な数字、歴史的な利害、環境的危険性で問題をエスカレートさせます。比較メトリクスやステータス HUD バーを導入します。
3. 巧妙なピボット20.0s – 45.0s12.0s – 22.0s建築的または科学的秘密が明らかになります。アニメーション図、カットアウェイ、ルート、コンポーネントレイヤーを使用してエンジニアリングメカニズムを分解します。
4. ペイオフ & トレーリングブリッジ最後の5.0s最後の4.0s「ああ!」というペイオフを届け、ゼロテーリングサイレンスでオープニングフックにループバックするトレーリングセンテンスブリッジで終了します。
  • スピーキングペーシング:毎分150~170語(WPM)。
  • ターゲットワードカウント:
    • 45~55秒 Short:110~145語。
    • 25~30秒 Short:65~75語。

3. ビジュアル & レイアウトルール

  1. キャンバス仕様:
    • 解像度: 1080x1920(9:16 縦長アスペクト比)。
    • フレームレート: 60 FPS 固定。
    • ベクター精度: すべての図形、アイコン、線は鮮明なアンチエイリアシングのために PyCairo を介して描画されます。
  2. セーフゾーン(モバイル UI に重要):
    • トップセーフゾーン($y \ge 130$):タイトル、チャプターピル、トップバナーは、チャンネルハンドルと上部 UI オーバーレイを避けるために $y = 130$ から $y = 260$ の間に配置します。
    • センターアクションゾーン($y = 280$ から $y = 1420$):すべての焦点グラフィック、カットアウェイ、マップルート、統計バッジはここに配置します。
    • ボトムキャプションセーフゾーン:キャプションは $y = 1480$–$1540$ に固定されます。背景の焦点要素は $y = 1400$ より上に保ち、キャプションテキストとの重なりを防ぎます。下部380px(YouTube と TikTok でタイトル、サウンド、チャンネル登録ボタンが配置される場所)には、重要な視覚的詳細を避けてください。
  3. ペーシングルール(3~5秒ごとの状態変更):
    • 視聴者の注意は急速に衰えます。3~5秒 ごとに視覚的状態、カメラフォーカスを変更するか、アニメーションをトリガーします(例:バッジのスプリングポップ、HUD メトリックのフィル、パスの進行、レイヤーの表示)。
  4. 正確なワードシンクサウンドデザイン:
    • 決して SFX タイミングを推測しないでください。
    • 最初に generate_voiceover を実行して words.json を生成します。
    • words.json を検査してキートリガーワード(例:数字、アクション動詞、シーン遷移)を見つけ、それらの単語の正確な start_sec に whoosh、pop、thud のキューを配置します。

4. 完全なエンジン API リファレンス

すべてのモジュールは engine からきれいにエクスポートされています:

from engine import (
    # レンダラー
    VideoRenderer,
    # イージング数学
    clamp, lerp, ease_out_cubic, ease_in_out_cubic, spring_pop,
    cubic_bezier_point, cubic_bezier_tangent, cubic_bezier_angle,
    # 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,
    # アクティブワードカラオケキャプション
    draw_karaoke_captions, chunk_words,
    # オーディオ合成、ミキシング & マルチプレクサ
    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: ...

イージングヘルパー(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: ...

再利用可能なベクタープリミティブ(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: ...

アクティブワードカラオケキャプション(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: ...

ボイスオーバー、効果音 & ミキシング(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. 自律実行ワークフロー

任意のトピックで Short を生成するタスクが与えられた場合、これらの手順を順次実行します:

ステップ 1: スクリプト構成(センテンスブリッジルール)

4部構成の公式に従って、タイトな65~140語のスクリプトを作成します。

  • 必須:鋭い Why / How / What の質問で始める。
  • 必須:トレーリングセンテンスブリッジ(「...だからこそ、今でも人々はこう尋ねるのだ、」)で終わる。
  • 明確なトピックスラッグを選択します(例:colosseum_flood や vibranium_physics)。

ステップ 2: scratch/ でのボイスオーバー & タイムスタンプ生成

すべての中間ファイルを scratch/ に保存して、output/ をクリーンに保ちます:

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

scratch/<slug>_words.json を検査して、以下を決定します:

  • 正確な audio_duration
  • キーマイルストーンワードのタイムスタンプ(例:フックが終わる場所、数字が話される場所、ピボットと最終ブリッジが発生する場所)。

ステップ 3: スクリプト化されたシーンパイプライン生成

projects/<slug>.py を作成し、engine からインポートします:

  • total_duration = audio_dur + 0.05 を設定します(インスタントループを維持するためにゼロテーリングサイレンス)。
  • 中間ビデオ/オーディオファイルを scratch/<slug>_silent.mp4 と scratch/<slug>_audio.aac に書き込みます。
  • ヒーロー図のための draw_anchor_state(ctx, zoom, pan) を定義します。
  • オープニングの $0.0\text{s} \to 1.2\text{s}$ では、ease_out_cubic を介して zoom = 1.45 -> 1.0 で draw_anchor_state を使用します。
  • 最後の $1.2\text{s}$($t_{\text{close}} \to \text{total_duration}$)では、ease_in_out_cubic を介して zoom = 1.0 -> 1.45 で draw_anchor_state を使用し、フレーム0とのピクセル単位で同一のマッチカットを保証します。
  • scratch/<slug>_words.json から実際のナレーションペーシングに一致する中間シーン遷移を定義します。
  • SFX イベント [(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')] を正確なワードタイムスタンプにマッピングします。
  • draw_frame(ctx, frame_idx, time_sec) レンダリング関数を実装...

[以下、翻訳のため切り詰められました]

関連スキル