Communitygithub.com

Tanush-Gupta1812/py-shorts-studio

使用 PyCairo、Edge TTS 主动词卡拉 OK 字幕、程序化音效和直接 FFmpeg 管道编码,生成、脚本化、动画化并渲染热门的 60fps 竖屏 2D 矢量解说视频、YouTube Shorts 和 Reels。强制实现无缝音频和视觉匹配循环,以实现无限留存率。

py-shorts-studio 是什么?

Py-Shorts-Studio 是一个高性能的 Python 框架,专为创建竖屏 (1080x1920, 9:16)、60 FPS 的动态图形解说 Shorts 而设计。它通过将 PyCairo 矢量帧直接流式传输到 FFmpeg 标准输入来绕过磁盘 I/O,将配音与主动词卡拉 OK 字幕对齐,合成自定义音效,并混音多轨音频。该工具强制实施无缝的音频和视觉匹配循环,以实现无限留存率,并通过精确的帧级控制确保视频的最后一帧在像素上与第一帧相同。它支持 Claude Code、Cursor 和 Codex 等 AI 智能体平台,为开发者提供一个完整的 Python API,用于自动化生成病毒式传播的短视频内容。该技能内置了四部分高留存率脚本公式、用于持续循环的句子桥接规则以及基于精确单词时间戳的程序化音效设计。

兼容平台~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 标准输入来绕过磁盘 I/O,将配音与主动词卡拉 OK 字幕对齐,合成自定义 SFX,并混音多轨音频。


1. 无缝音频 + 视觉匹配切断循环(无限留存率)

为了最大化平均观看百分比(在 YouTube Shorts 和 Instagram Reels 上实现 >100% 的循环留存率),生成的每个 Short 必须强制实施音频句子桥接和像素相同的视觉匹配切断。

A. 句子桥接脚本规则

当 YouTube 循环播放一个 Short 时,音频的结尾会瞬间连接到开头。脚本必须利用这一点:

  1. 开场钩子:必须立即以一个尖锐的 为什么 / 如何 / 什么 问题开始。
    • 示例:“为什么钢铁侠的战衣会在三秒内杀死托尼·斯塔克?”
    • 示例:“为什么现代高速公路在十五年内就崩溃了,而罗马的道路却维持了两千年?”
  2. 结尾引导短语:最后一个句子必须以一个尾随的、预期的引导短语结束,而不是一个确定的结束语或订阅号召。
    • 示例:“……这就是为什么人们仍然会问,”
    • 示例:“……所以下次有人问你的时候,”
    • 示例:“……而这解释了,”
  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. 四部分高留存率脚本公式

高留存率的 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. 安全区域(对移动用户界面至关重要):
    • 顶部安全区域($y \ge 130$):将标题、章节药丸和顶部横幅放置在 $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,
    # 主动词卡拉 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) 渲染函数。

...

相关技能