Communitygithub.com

Py-Shorts-Studio:爆紅2D向量解說影片引擎

使用 PyCairo、Edge TTS 即時卡拉OK字幕、程式化音效與 FFmpeg 管道編碼,自動生成、編寫腳本、動畫並渲染 60fps 垂直 2D 向量解說影片、YouTube Shorts 與 Reels。強制實現無縫音訊與視覺匹配剪輯循環,以達到無限留存效果。

Py-Shorts-Studio:爆紅2D向量解說影片引擎 是什麼?

Py-Shorts-Studio 是一個高效能的 Python 框架,專為創建垂直格式(1080x1920,9:16)、60 FPS 的動態圖形解說 Shorts 而設計。它透過將 PyCairo 向量幀直接串流到 FFmpeg stdin 來繞過磁碟 I/O,並將語音與即時卡拉OK字幕同步,同時合成自訂音效與混音。該框架的核心是強制執行「無縫音訊與視覺匹配剪輯循環」,以實現無限留存:腳本必須以銜接性句子結束,形成語法循環,且影片的第一幀與最後一幀必須畫素完全相同,觀眾無法察覺重複。它支援 Claude Code、Cursor 與 Codex 等 AI 代理平台,透過從 engine 模組匯入的完整 API 進行自主執行,涵蓋渲染、緩動數學、2D 向量圖元、卡拉OK字幕、語音生成及音訊合成。工作流程遵循四部分高留存腳本公式,並強制執行精確的視覺佈局規則(如安全區域、3-5 秒狀態變化)與字詞同步音效設計。

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

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Py-Shorts-Studio:爆紅2D向量解說影片引擎 是做什麼的?

py-shorts-studio 是一個高效能 Python 框架,用於創建垂直(1080x1920,9:16)、60 FPS 的動態圖形解說 Shorts。它透過將 PyCairo 向量幀直接串流到 FFmpeg stdin 來繞過磁碟 I/O,將語音與即時卡拉OK字幕同步,合成自訂 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, "英雄主題標題", "工程解說", 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. 四部分高留存腳本公式

高留存 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.0秒最後4.0秒傳達「啊哈!」的回報,並以一個引導句子橋接結尾,無尾音靜音地循環回開場鉤子。
  • 說話節奏:每分鐘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$):將標題、章節膠囊和頂部橫幅放置在 $y = 130$ 和 $y = 260$ 之間,以避免頻道處理和頂部 UI 疊加層。
    • 中心動作區域($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,
    # 即時卡拉OK字幕
    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: ...

即時卡拉OK字幕 (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:腳本組成(句子橋接規則)

按照四部分公式撰寫一個緊湊的 65–140 字腳本。

  • 必須以尖銳的 為什麼 / 如何 / 什麼 問題開頭。
  • 必須以引導句子橋接結尾("...這就是為什麼人們仍然會問,")。
  • 選擇一個明確的主題別名(例如 colosseum_flood 或 vibranium_physics)。

步驟 2:語音與時間戳生成在 scratch/ 中

將所有中間檔案保存在 scratch/ 中,以保持 output/ 乾淨:

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

檢查 scratch/<別名>_words.json 以確定:

  • 確切的 audio_duration
  • 關鍵里程碑字詞時間戳(例如鉤子結束的位置、說出數字的位置、轉折點和最終橋接發生的位置)。

步驟 3:腳本化場景管線生成

創建 projects/<別名>.py 並從 engine 匯入:

  • 設定 total_duration = audio_dur + 0.05(零尾音靜音以維持即時循環)。
  • 將中間的影片/音訊檔案寫入 scratch/<別名>_silent.mp4 和 scratch/<別名>_audio.aac。
  • 為英雄圖表定義 draw_anchor_state(ctx, zoom, pan)。
  • 開場 $0.0\text{s} \to 1.2\text{s}$ 使用 draw_anchor_state,透過 ease_out_cubic 縮放 zoom = 1.45 -> 1.0。
  • 最後 $1.2\text{s}$($t_{\text{close}} \to \text{total_duration}$)使用 draw_anchor_state,透過 ease_in_out_cubic 縮放 zoom = 1.0 -> 1.45,確保與第 0 幀進行畫素完全相同的匹配剪輯。
  • 定義符合 scratch/<別名>_words.json 實際敘事節奏的中間場景轉換。
  • 將 SFX 事件 [(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')] 對應到精確的字詞時間戳。
  • 實現 draw_frame(ctx, frame_idx, time_sec) 渲染函數,處理所有場景轉換與動畫。

步驟 4:無聲影片渲染與音訊混合

from engine import VideoRenderer, mix_final_audio, mux_video_audio

# 渲染無聲影片(無音訊路徑)
renderer = VideoRenderer(output_path="scratch/<別名>_silent.mp4")
renderer.render(draw_fn=draw_frame, duration_sec=total_duration)

# 混合音效與語音
mix_final_audio("scratch/<別名>_vo.mp3", sfx_events, "scratch/<別名>_audio.aac", total_duration)

# 封裝最終影片
final_path = mux_video_audio("scratch/<別名>_silent.mp4", "scratch/<別名>_audio.aac")

步驟 5:最終驗證

  • 播放 output/ 中的最終 .mp4 檔案。
  • 確認影片確實從 zoom 1.45 開始和結束。
  • 確認在影片開頭/結尾邊界上沒有可感知的視覺跳動或音訊間隙。
  • 驗證字幕在敘事過程中準確高亮對應的字詞。

6. 專案結構約定

建議的目錄佈局:

<project_root>/
├── engine/            # 框架程式碼(已提供)
│   ├── __init__.py
│   ├── renderer.py
│   ├── easing.py
│   ├── primitives.py
│   ├── captions.py
│   ├── tts_align.py
│   └── audio_mixer.py
├── projects/          # 每個 Short 一個腳本
│   ├── colosseum_flood.py
│   ├── vibranium_physics.py
│   └── ...
├── scratch/           # 中間檔案(語音、words.json、無聲影片、混合音訊)
│   ├── colosseum_flood_vo.mp3
│   ├── colosseum_flood_words.json
│   ├── colosseum_flood_silent.mp4
│   ├── colosseum_flood_audio.aac
│   └── ...
├── output/            # 最終渲染結果
│   ├── colosseum_flood.mp4
│   └── ...
└── assets/
    └── sfx/           # 預設音效
        ├── whoosh.mp3
        ├── pop.mp3
        ├── thud.mp3
        └── ambient.mp3

7. 效能注意事項

  • 直接管道模式:始終對 PyCairo 表面使用 ImageSurface.format.ARGB32。FFmpeg 從 stdin 接受原始 BGRA 資料。
  • 預設編碼器:libx264 搭配 -preset fast -crf 18。對於現代 GPU,可將 ffmpeg_bin 替換為 NVIDIA NVENC 或 AMD AMF 二進位檔案。
  • 避免磁碟瓶頸:永遠不要將單獨的幀寫入磁碟。使用 renderer.render() 內部緩衝來串流到 FFmpeg stdin。
  • 記憶體管理:PyCairo 表面以 ARGB32(每畫素 4 字節)儲存。單個 1080x1920 幀約為 8.29 MB。在 60 FPS 下,保持內部緩衝區小於 5 幀,以將記憶體使用量控制在 ~50 MB 以下。

相關技能