Communitygithub.com

py-shorts-studio

고성능 Python 프레임워크로, 바이럴 60fps 세로형 2D 벡터 설명 영상(YouTube Shorts, Reels)을 생성, 스크립팅, 애니메이션 및 렌더링합니다. PyCairo, Edge TTS 능동 단어 가라오케 자막, 절차적 사운드 효과 및 FFmpeg 파이프 인코딩을 활용하며, 무한 시청 지속율을 위한 매끄러운 오디오 및 시각적 매치컷 루프를 적용합니다.

py-shorts-studio란 무엇인가요?

`py-shorts-studio`는 개발자와 AI 에이전트가 바이럴 YouTube Shorts, Instagram Reels, 세로형 설명 영상을 제작할 수 있도록 설계된 Python 기반 프레임워크입니다. PyCairo를 사용하여 1080x1920 해상도, 60FPS의 2D 벡터 모션 그래픽을 생성하고, FFmpeg stdin으로 직접 스트리밍하여 디스크 I/O를 우회합니다. 주요 특징으로는 Edge TTS를 활용한 능동 단어 가라오케 자막, 절차적 사운드 효과 합성, 다중 트랙 오디오 믹싱, 그리고 '문장 브리지' 스크립트 규칙과 '픽셀 동일 매치컷'을 통한 무한 시청 지속율 루프가 포함됩니다. 이 스킬은 Claude Code, Cursor 또는 Codex와 같은 AI 코딩 에이전트가 'YouTube Short 생성' 요청 시 자율적으로 실행되며, 스크립트 작성, 음성 더빙 생성, 장면 구성, 렌더링까지 전 과정을 자동화합니다. 프레임워크는 4단계 고시청률 스크립트 공식(역설적 훅, 제약/고조, 기발한 전환, 대가/연결 브리지)을 따르고, 3~5초마다 시각적 변화를 주어 시청자 이탈을 방지합니다. 모든 중간 파일은 `scratch/` 디렉토리에 저장되며, 최종 출력은 `output/`에 생성됩니다.

지원 대상~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) 60FPS 모션 그래픽 설명 Shorts를 제작하는 고성능 Python 프레임워크입니다. PyCairo 벡터 프레임을 FFmpeg stdin으로 직접 스트리밍하여 디스크 I/O를 우회하고, 음성 더빙에 능동 단어 가라오케 자막을 정렬하며, 사용자 정의 SFX를 합성하고, 다중 트랙 오디오를 믹싱합니다.


1. 매끄러운 오디오 + 시각적 매치컷 루프 (무한 시청 지속율)

Average Percentage Viewed(YouTube Shorts 및 Instagram Reels에서 100% 이상 루프 시청 지속율)를 극대화하기 위해, 생성되는 모든 Short는 오디오 문장 브리지와 픽셀 동일 시각적 매치컷을 모두 강제로 적용해야 합니다.

A. 문장-브리지 스크립트 규칙

YouTube가 Shorts를 반복 재생할 때, 오디오의 끝은 시작 부분에 즉각적으로 연결됩니다. 스크립트는 이를 활용해야 합니다:

  1. 오프닝 훅: 반드시 왜(Why) / 어떻게(How) / 무엇(What) 질문으로 즉시 시작해야 합니다.
    • 예시: "왜 아이언맨 수트가 3초 만에 토니 스타크를 죽일까요?"
    • 예시: "현대 고속도로는 15년 만에 부서지는데 로마 도로는 2천 년을 버티는 이유는?"
  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. 고시청률을 위한 4파트 스크립트 공식

고시청률 Shorts는 다음의 엄격한 내러티브 호를 따릅니다:

파트타이밍 (45–55초 Short)타이밍 (25–30초 Short)목적 및 실행
1. 역설적 훅0.0s – 4.0s0.0s – 5.0s왜 / 어떻게 / 무엇으로 시작하는 날카롭고 반직관적인 대비 또는 불가능한 미스터리를 제시합니다. 잡담, 인트로, 인사는 금지합니다.
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$): 제목, 챕터 필 및 상단 배너를 $y = 130$과 $y = 260$ 사이에 배치하여 채널 핸들 및 상단 UI 오버레이를 피합니다.
    • 중앙 액션 영역 ($y = 280$ ~ $y = 1420$): 모든 초점 그래픽, 컷어웨이, 지도 경로 및 상태 배지는 여기에 위치합니다.
    • 하단 자막 안전 영역: 자막은 $y = 1480$–$1540$에 고정됩니다. 배경 초점 요소는 $y = 1400$ 위에 유지하여 자막 텍스트와 겹치는 것을 방지합니다. YouTube 및 TikTok에서 제목, 사운드 및 구독 버튼이 있는 하단 380px에는 필수 시각적 세부 정보를 배치하지 마십시오.
  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/에서 음성 더빙 및 타임스탬프 생성

output/을 깨끗하게 유지하기 위해 모든 중간 파일을 scratch/에 저장합니다:

.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단계: 스크립트 기반 장면 파이프라인 생성

engine에서 가져오는 projects/<slug>.py를 생성합니다:

  • 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을 통해 draw_anchor_state를 zoom = 1.45 -> 1.0으로 사용합니다.
  • 마지막 $1.2\text{s}$ ($t_{\text{close}} \to \text{total_duration}$)에는 ease_in_out_cubic을 통해 draw_anchor_state를 zoom = 1.0 -> 1.45로 사용하여 프레임 0과 픽셀 동일 매치컷을 보장합니다.
  • scratch/<slug>_words.json의 실제 내레이션 페이싱과 일치하는 중간 장면 전환을 정의합니다.
  • SFX 이벤트 [(t1, 'whoosh'), (t2, 'pop'), (t3, 'thud')]를 정확한 단어 타임스탬프에 매핑합니다.
  • df 함수를 구현합니다.

관련 스킬