Py-Shorts-Studio: 病毒式传播的 2D 矢量解说引擎
py-shorts-studio 是一个高性能的 Python 框架,用于创建竖屏 (1080x1920, 9:16)、60 FPS 的动态图形解说 Shorts。它通过将 PyCairo 矢量帧直接流式传输到 FFmpeg 标准输入来绕过磁盘 I/O,将配音与主动词卡拉 OK 字幕对齐,合成自定义 SFX,并混音多轨音频。
1. 无缝音频 + 视觉匹配切断循环(无限留存率)
为了最大化平均观看百分比(在 YouTube Shorts 和 Instagram Reels 上实现 >100% 的循环留存率),生成的每个 Short 必须强制实施音频句子桥接和像素相同的视觉匹配切断。
A. 句子桥接脚本规则
当 YouTube 循环播放一个 Short 时,音频的结尾会瞬间连接到开头。脚本必须利用这一点:
- 开场钩子:必须立即以一个尖锐的 为什么 / 如何 / 什么 问题开始。
- 示例:
“为什么钢铁侠的战衣会在三秒内杀死托尼·斯塔克?” - 示例:
“为什么现代高速公路在十五年内就崩溃了,而罗马的道路却维持了两千年?”
- 示例:
- 结尾引导短语:最后一个句子必须以一个尾随的、预期的引导短语结束,而不是一个确定的结束语或订阅号召。
- 示例:
“……这就是为什么人们仍然会问,” - 示例:
“……所以下次有人问你的时候,” - 示例:
“……而这解释了,”
- 示例:
- 连续的语法循环:当视频从引导短语循环回开场问题时,它形成一个无缝的语法句子:
“……这就是为什么人们仍然会问——为什么钢铁侠的战衣会在三秒内杀死托尼·斯塔克?”
- 零尾音静音:
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 = 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.0s | 最后 4.0s | 提供“啊哈!”的回报,并以一个尾随的句子桥接结束,零尾音静音地循环回开场钩子。 |
- 语速:每分钟 150–170 个词(WPM)。
- 目标词数:
- 45–55秒 Short:110–145 个词。
- 25–30秒 Short:65–75 个词。
3. 视觉与布局规则
- 画布规格:
- 分辨率:
1080x1920(9:16 竖屏宽高比)。 - 帧率:
60 FPS恒定。 - 矢量精度:所有形状、图标和线条均通过 PyCairo 绘制,以获得清晰的抗锯齿效果。
- 分辨率:
- 安全区域(对移动用户界面至关重要):
- 顶部安全区域($y \ge 130$):将标题、章节药丸和顶部横幅放置在 $y = 130$ 到 $y = 260$ 之间,以避免频道句柄和顶部用户界面叠加。
- 中央操作区域($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('''<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)。 - 使用
draw_anchor_state配合zoom = 1.45 -> 1.0,通过ease_out_cubic实现开场 $0.0\text{s} \to 1.2\text{s}$。 - 使用
draw_anchor_state配合zoom = 1.0 -> 1.45,通过ease_in_out_cubic实现最终 $1.2\text{s}$($t_{\text{close}} \to \text{total_duration}$),确保与第 0 帧实现像素相同的匹配切断。 - 定义与来自
scratch/<slug>_words.json的实际叙述节奏相匹配的中间场景转换。 - 将 SFX 事件
[(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')]映射到精确的词时间戳。 - 实现
draw_frame(ctx, frame_idx, time_sec)渲染函数。
...