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 時,音訊的結尾會瞬間連接到開頭。腳本必須利用這點:
- 開場鉤子:必須立即以尖銳的 為什麼 / 如何 / 什麼 問題開場。
- 範例:
"為什麼鋼鐵人的盔甲會在3秒內殺死東尼·史塔克?" - 範例:
"為什麼現代高速公路15年內就會崩潰,而羅馬道路卻能持續2000年?"
- 範例:
- 結尾引導詞:最後一句話必須以一個懸念、預期性的引導詞結尾,而不是確定的結束語或訂閱呼籲:
- 範例:
"...這就是為什麼人們仍然會問," - 範例:
"...所以下次有人問你的時候," - 範例:
"...而這解釋了,"
- 範例:
- 連續語法循環:當影片從引導詞循環到開場問題時,形成一個連貫的語法句:
"...這就是為什麼人們仍然會問 —— 為什麼鋼鐵人的盔甲會在3秒內殺死東尼·史塔克?"
- 零尾音靜音:
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 = 0.0\text{s}$ 時,以
- 閉幕($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.0s | 0.0s – 5.0s | 陳述一個 stark、反直覺的對比或不可能的謎團,以 為什麼 / 如何 / 什麼 開頭。禁止廢話、介紹或問候。 |
| 2. 限制 / 升級 | 4.0s – 20.0s | 5.0s – 12.0s | 用具體數字、歷史風險或環境危害來升級問題。引入比較指標或狀態 HUD 條。 |
| 3. 巧妙轉折 | 20.0s – 45.0s | 12.0s – 22.0s | 揭示建築或科學秘密。使用動畫圖表、剖面圖、路線或元件分層來分解工程機制。 |
| 4. 回報與引導橋接 | 最後5.0秒 | 最後4.0秒 | 傳達「啊哈!」的回報,並以一個引導句子橋接結尾,無尾音靜音地循環回開場鉤子。 |
- 說話節奏:每分鐘150–170 個單詞 (WPM)。
- 目標字數:
- 45–55秒 Short:110–145 個單詞。
- 25–30秒 Short:65–75 個單詞。
3. 視覺與佈局規則
- 畫布規格:
- 解析度:
1080x1920(9:16 垂直寬高比)。 - 幀率:
60 FPS恆定。 - 向量精度:所有形狀、圖標和線條均通過 PyCairo 繪製,以實現清晰的抗鋸齒。
- 解析度:
- 安全區域(對行動裝置 UI 至關重要):
- 頂部安全區域($y \ge 130$):將標題、章節膠囊和頂部橫幅放置在 $y = 130$ 和 $y = 260$ 之間,以避免頻道處理和頂部 UI 疊加層。
- 中心動作區域($y = 280$ 到 $y = 1420$):所有焦點圖形、剖面圖、地圖路線和狀態徽章都位於此處。
- 底部字幕安全區域:字幕固定在 $y = 1480$–$1540$。將背景焦點元素保持在 $y = 1400$ 以上,以防止與字幕文字重疊。避免在底部 380px(YouTube 和 TikTok 上的標題、聲音和訂閱按鈕所在位置)放置必要的視覺細節。
- 節奏規則(3–5 秒狀態變化):
- 觀眾注意力會迅速下降。每 3 到 5 秒 更改一次視覺狀態、攝影機焦點或觸發動畫(例如徽章彈跳、HUD 指標填充、路徑進展或圖層揭示)。
- 精確的字詞同步音效設計:
- 永遠不要猜測 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 以下。