vid-motion-graphics
헤드리스 Chromium(Playwright)에서 HTML/CSS 애니메이션을 프레임별로 렌더링하고 FFmpeg으로 PNG 프레임을 조합하여 다중 장면 모션 그래픽 MP4를 생성합니다. React, AI API, Python 없음 — graphic-gif 계열 외에 새로운 의존성이 없습니다.
CDN 폰트만 사용합니다. HTML에 외부 라이브러리 없음.
중요 규칙 (모든 생성 전 읽을 것)
window.renderFrame(t)사용 — 장면 전환에 CSS@keyframes사용 금지. Chromium에서 CSS 애니메이션currentTime탐색은 역방향 탐색 시 자동으로 무시됩니다. renderFrame 접근 방식: 순수 JS 함수가 밀리초 단위로opacity/transform을 직접 계산합니다. Playwright가 프레임당 한 번 호출합니다. 결정적이며, 경쟁 상태가 없습니다.- 어떤 요소에도
animation-delay사용 금지. renderFrame에서는 필요하지 않습니다.animation-delay를 작성하고 있다면 잘못된 아키텍처를 사용하고 있는 것입니다. window.__videoReady = true는document.fonts.ready.then(...)내부에서만 설정. 절대 동기적으로 설정하지 마세요 — 폰트가 로드되어야 Playwright가 프레임 1을 캡처하거나 텍스트가 대체 폰트로 렌더링되지 않습니다.window.__stopPreview()노출. 브라우저의 rAF 미리보기 루프는 Playwright의 evaluate/screenshot 호출과 경쟁합니다.capture-frames.mjs는 프레임 루프 전에__stopPreview()를 호출합니다. 항상 포함하세요.- 장면 경계 확인 시
t < startMs(<=아님) 사용. 프레임 0에서t <= 0은 장면 1을 검게 만듭니다. 올바른 가드는if (t < startMs || t >= endMs) return hidden입니다. - Body = 정확한 픽셀 치수. 너비와 높이는 정수(
1080px,1920px)입니다.%,vw/vh, 반응형 단위 사용 금지. - 두 장면이 동시에 표시되지 않음 (10% 진입 겹침 제외). 모든 장면은 renderFrame 윈도우 외부에서
opacity: 0입니다. - 전환은
opacity만 사용.display토글,visibility사용 금지 — GPU 합성된 opacity는 프레임 단위로 완벽합니다. - HTML을 채팅에 덤프하지 마세요. 파일로 저장하고 요약만 표시하세요.
- 제목은 주제가 아닌 메시지를 나타냅니다. "Q4 2024 실적 비디오"가 아닌 "Q4 목표를 달성한 3가지 이유".
- HTML을 생성하기 전에
references/scene-library.md를 읽으세요. 해당 파일의 정확한 HTML 구조와 CSS 클래스 이름을 사용하세요.
1단계: 입력 수집
필수: content_brief
선택적 매개변수 및 기본값:
| 매개변수 | 기본값 | 설명 |
|---|---|---|
| content_brief | — | 비디오가 전달할 내용을 설명하는 텍스트 (필수) |
| scenes | auto | 장면 수 (1–6). Auto = 브리프에서 파생. |
| duration_per_scene | 3s | 장면당 시간 (초, 1–8s) |
| style | kinetic-dark | kinetic-dark / editorial-light / data-pulse / bold-type / minimal-clean |
| aspect_ratio | 1:1 | 1:1 (1080×1080) / 16:9 (1920×1080) / 9:16 (1080×1920) |
| fps | 30 | 초당 프레임 (24, 30, 60) |
| music | none | 배경 트랙용 오디오 파일 경로 (mp3/m4a/wav) |
| source | — | 최종 프레임 하단에 표시되는 출처 표시 |
content_brief가 없으면 정확히 다음과 같이 질문하세요:
"비디오를 만들려면 콘텐츠 브리프가 필요합니다 — 비디오가 무엇을 전달해야 하나요?
예: 'Q4 수익이 85% 성장한 3가지 이유 보여주기: 신규 엔터프라이즈 거래, 이탈 감소, 가격 인상. 굵은 숫자 사용. 스타일: data-pulse.'
선택 사항: 스타일 (기본값: kinetic-dark), 화면 비율 (기본값: 1:1), 장면당 초 (기본값: 3s)"
content_brief가 있으면 → 즉시 2단계로 진행합니다.
2단계: 내부 아키텍처 (사용자에게 절대 표시되지 않음)
1. 브리프를 장면으로 파싱 (최대 6개):
- 장면 1 = 후크 또는 제목 (항상)
- 장면 2–N-1 = 뒷받침 포인트, 지표 또는 스토리 비트
- 장면 N = CTA 또는 마무리 요약 (장면이 2개 이상인 경우 항상)
- 장면당 하나의 핵심 아이디어 — 브리프에 7개 이상의 아이디어가 있으면 가장 약한 것을 통합
2. references/scene-library.md 읽기 — 각 장면에 대한 장면 유형 선택:
- 후크/오프닝 →
title-card - 단일 지표 →
stat-reveal - 2–4개 포인트 목록 →
bullet-list - 이전 vs 이후 / 두 값 →
split-screen - 사용후기 / 인용문 →
quote-card - 최종 / CTA →
cta-card
3. references/style-presets.md 읽기 — 선택한 스타일에 대한 CSS 토큰 + 애니메이션 특성 로드
4. 타이밍 계산:
totalDuration = sceneCount × duration_per_scene (초)
totalFrames = totalDuration × fps
각 장면은 전체 @keyframes 범위의 (100 / sceneCount)%를 차지합니다.
| 장면 | 시작 % | 종료 % |
|---|---|---|
| 1 | 0% | (100/N)% |
| 2 | (100/N)% | (200/N)% |
| … | … | … |
| N | ((N-1)×100/N)% | 100% |
각 장면 범위 내:
- 진입: 장면 범위의 처음 10%
- 유지: 장면 범위의 10%~85%
- 종료: 장면 범위의 85%~100%
5. 픽셀 치수 결정:
1:1→ W=1080, H=108016:9→ W=1920, H=10809:16→ W=1080, H=1920
3단계: HTML 생성
코드를 작성하기 전에 references/scene-library.md와 references/style-presets.md를 읽으세요.
필수 HTML 구조:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
[스타일 프리셋의 폰트 CDN 링크]
<style>
:root {
[스타일 프리셋의 모든 CSS 토큰]
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: [W]px; height: [H]px;
overflow: hidden;
background: var(--bg);
font-family: var(--font-body);
position: relative;
}
.scene {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px;
opacity: 0;
will-change: opacity, transform;
}
.scene-inner {
width: 100%;
max-width: 960px;
}
/* scene-library.md의 장면 유형 CSS */
[여기에 장면 유형 CSS 붙여넣기]
</style>
</head>
<body>
<div class="scene scene-1">
<div class="scene-inner">
[scene-library.md 템플릿의 장면 1 HTML]
</div>
</div>
[각 장면에 대해 반복]
<script>
window.__videoReady = false;
window.TOTAL_DURATION_MS = [totalDuration * 1000];
// ── 애니메이션 헬퍼 ─────────────────────────────────────────────────────────
function lerp(a, b, p) { return a + (b - a) * p; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3); }
function sceneState(t, startMs, endMs) {
if (t < startMs || t >= endMs) return { opacity: 0, ty: 0 };
const prog = (t - startMs) / (endMs - startMs);
if (prog < 0.10) {
const p = easeOutCubic(prog / 0.10);
return { opacity: p, ty: lerp(24, 0, p) };
}
if (prog < 0.85) return { opacity: 1, ty: 0 };
const p = (prog - 0.85) / 0.15;
return { opacity: 1 - p, ty: lerp(0, -12, p) };
}
function applySceneState(el, state) {
el.style.opacity = state.opacity;
el.style.transform = state.ty !== 0 ? `translateY(${state.ty.toFixed(2)}px)` : '';
}
// ── 주요 렌더 함수 — Playwright가 프레임당 한 번 호출 ────────────────────────
window.renderFrame = function(t) {
// 장면 1: 0ms – [D]ms
applySceneState(document.querySelector('.scene-1'), sceneState(t, 0, [D]));
// 장면 2: [D]ms – [2D]ms
applySceneState(document.querySelector('.scene-2'), sceneState(t, [D], [2D]));
// ... 각 장면에 대해 반복 ...
};
// ── 미리보기 루프 — 프레임 캡처 전에 Playwright에 의해 중지됨 ─────────────────
let __previewActive = false;
let __previewRafId = null;
window.__stopPreview = function() {
__previewActive = false;
if (__previewRafId !== null) { cancelAnimationFrame(__previewRafId); __previewRafId = null; }
};
document.fonts.ready.then(() => {
window.renderFrame(0);
window.__videoReady = true;
__previewActive = true;
const startTime = performance.now();
function previewTick() {
if (!__previewActive) return;
const elapsed = performance.now() - startTime;
if (elapsed < window.TOTAL_DURATION_MS) {
window.renderFrame(elapsed);
__previewRafId = requestAnimationFrame(previewTick);
} else {
window.renderFrame(window.TOTAL_DURATION_MS - 1);
__previewActive = false;
}
}
__previewRafId = requestAnimationFrame(previewTick);
});
</script>
</body>
</html>
디자인 품질 규칙:
- 헤드라인 글꼴 크기: 60px 미만 금지 — 모바일 썸네일 크기에서도 텍스트를 읽을 수 있어야 함
- 숫자 및 통계: 항상 화면에서 가장 큰 요소 (120–200px)
- 디스플레이 유형에 좁은 자간:
-0.02em에서-0.04em - 장면당 하나의 강조 색상 — 여러 요소에 강조를 분산시키지 마세요
- 다크 프리셋: 구분선
rgba(255,255,255,0.10), 절대 단색 사용 금지 - 라이트 프리셋: 구분선
rgba(0,0,0,0.10), 절대 단색 사용 금지 transform을 사용하는 모든 요소에transform-origin: center center.scene내부 패딩: 최소 80px — 텍스트가 뷰포트 가장자리에 닿지 않도록 함
4단계: 자체 QA (5단계 전에 모든 실패 수정)
renderFrame 정확성:
-
window.renderFrame(t)정의됨 — 순수 함수, 스타일 쓰기 외에 부작용 없음 - 장면 경계가
t < startMs(t <= startMs아님) 사용 — 검은 프레임 0 방지 - 장면 전환을 위한
animation-delay또는@keyframes인스턴스 0개 - 두 장면이 겹치는
opacity: 1윈도우가 없음 (10% 진입 겹침 제외) -
window.__stopPreview()노출 및 미리보기 rAF 루프가__previewActive확인
준비 신호:
-
window.__videoReady = false가document.fonts.ready전에 선언됨 -
window.__videoReady = true가document.fonts.ready.then(...)내부에서만 설정됨 -
window.renderFrame(0)가__videoReady = true설정 전에document.fonts.ready.then(...)내부에서 호출됨
레이아웃:
-
html, body가 정확한 픽셀 치수 ([W]px,[H]px) 사용 -
body너비/높이에%,vw,vh,rem단위 없음 -
html, body에overflow: hidden - 모든 장면
position: absolute; inset: 0
디자인:
- 모든 색상이 스타일 프리셋 토큰에서 왔음 — 자유로운 16진수 값 없음
- 모든 폰트가 스타일 프리셋에서 왔음 — 자유로운 font-family 문자열 없음
- 헤드라인 글꼴 크기 ≥ 60px
-
source매개변수가 제공된 경우 최종 장면에 출처 표시
5단계: 내보내기
브리프 내용에서 슬러그 결정 (케밥 케이스, ≤30자):
mkdir -p chart/[slug]
HTML 저장: chart/[slug]/video.html
브라우저 미리보기:
open chart/[slug]/video.html
내보내기 실행 ([skill-root]를 이 스킬 디렉토리 경로로 교체):
bash [skill-root]/scripts/export-video.sh \
chart/[slug]/video.html \
chart/[slug]/video.mp4 \
--duration [totalDuration] \
--fps [fps] \
--width [W] \
--height [H] \
[--music path/to/audio.mp3]
이 스크립트는 첫 실행 시 Playwright를 설치하고 (~200MB Chromium), 모든 프레임을 캡처한 후 FFmpeg으로 MP4를 조합합니다.
6단계: 출력 요약
## 비디오: [브리프의 제목]
날짜: [YYYY-MM-DD] | 장면: [N] | 스타일: [style] | 화면 비율: [ratio]
길이: [N]초 | FPS: [fps] | 프레임: [N]
파일
소스: chart/[slug]/video.html
출력: chart/[slug]/video.mp4
크기: [X] MB
체크리스트
- [ ] 모든 장면이 순서대로 표시되고 빈 프레임이 없음
- [ ] 모바일 썸네일 크기에서 텍스트를 읽을 수 있음
- [ ] 장면 전환이 부드러움 (점프 컷 없음)
- [ ] 최종 장면에 CTA 또는 마무리 메시지 포함
- [ ] 출처 표시가 최종 프레임에 있음 (제공된 경우)
프롬프트 팁 (사용자가 안내를 요청할 때 표시)
"각 장면이 말해야 할 내용을 요점 위주로 콘텐츠 브리프를 제공하세요."
"인사이트를 직접적으로 명명하세요. 'Q4가 85% 성장한 3가지 이유'는 비디오에 뼈대를 제공합니다."
"선호하는 스타일이 있다면 언급하세요: kinetic-dark (기본값), editorial-light, data-pulse, bold-type, minimal-clean."
"플랫폼에 맞게 화면 비율을 지정하세요: LinkedIn/Instagram 피드는 1:1, Stories/Reels는 9:16, YouTube/프레젠테이션은 16:9."
✅ 좋음: "9초 비디오를 만들어 주세요. Q4 수익이 $4.2M (85% 성장)입니다. 동인: 엔터프라이즈 거래, 이탈률 1.2%, 가격 인상. CTA: acme.com/q4. 스타일: data-pulse. 화면 비율: 1:1."
❌ 나쁨: "회사에 관한 비디오를 만들어 줘"