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를 반복 재생할 때, 오디오의 끝은 시작 부분에 즉각적으로 연결됩니다. 스크립트는 이를 활용해야 합니다:
- 오프닝 훅: 반드시 왜(Why) / 어떻게(How) / 무엇(What) 질문으로 즉시 시작해야 합니다.
- 예시:
"왜 아이언맨 수트가 3초 만에 토니 스타크를 죽일까요?" - 예시:
"현대 고속도로는 15년 만에 부서지는데 로마 도로는 2천 년을 버티는 이유는?"
- 예시:
- 종료 연결 구문: 마지막 문장은 결정적인 종료 인사나 구독 요청 대신, 기대감을 남기는 연결 구문으로 끝나야 합니다:
- 예시:
"...그래서 사람들은 여전히 묻습니다," - 예시:
"...그러니 다음에 누군가 당신에게 묻는다면," - 예시:
"...그리고 그것이 설명하는 것은,"
- 예시:
- 연속적 문법 루프: 비디오가 연결 구문에서 오프닝 질문으로 반복될 때, 하나의 매끄러운 문장을 형성합니다:
"...그래서 사람들은 여전히 묻습니다 — 왜 아이언맨 수트가 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. 고시청률을 위한 4파트 스크립트 공식
고시청률 Shorts는 다음의 엄격한 내러티브 호를 따릅니다:
| 파트 | 타이밍 (45–55초 Short) | 타이밍 (25–30초 Short) | 목적 및 실행 |
|---|---|---|---|
| 1. 역설적 훅 | 0.0s – 4.0s | 0.0s – 5.0s | 왜 / 어떻게 / 무엇으로 시작하는 날카롭고 반직관적인 대비 또는 불가능한 미스터리를 제시합니다. 잡담, 인트로, 인사는 금지합니다. |
| 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로 그립니다.
- 해상도:
- 안전 영역 (모바일 UI에 매우 중요):
- 상단 안전 영역 ($y \ge 130$): 제목, 챕터 필 및 상단 배너를 $y = 130$과 $y = 260$ 사이에 배치하여 채널 핸들 및 상단 UI 오버레이를 피합니다.
- 중앙 액션 영역 ($y = 280$ ~ $y = 1420$): 모든 초점 그래픽, 컷어웨이, 지도 경로 및 상태 배지는 여기에 위치합니다.
- 하단 자막 안전 영역: 자막은 $y = 1480$–$1540$에 고정됩니다. 배경 초점 요소는 $y = 1400$ 위에 유지하여 자막 텍스트와 겹치는 것을 방지합니다. YouTube 및 TikTok에서 제목, 사운드 및 구독 버튼이 있는 하단 380px에는 필수 시각적 세부 정보를 배치하지 마십시오.
- 페이싱 규칙 (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,
# 능동 단어 가라오케 자막
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함수를 구현합니다.