콘텐츠
HTML과 Opus 5.5로 모션 그래픽 영상 만들기
비트 시트를 쓰고 Opus 5.5가 Felo API를 통해 결정론적 HTML 장면을 작성하게 한 뒤 Playwright로 프레임을 캡처하고 ffmpeg로 MP4로 인코딩합니다. WorkBuddy, 코드 실행이 되는 ChatGPT, Codex, Pi Agent, Claude Code에서 실행할 수 있습니다.
프롬프트 복사
대괄호 항목을 바꾼 뒤 Claude Code, Codex 또는 터미널에 접근할 수 있는 AI에 붙여넣으세요. Felo 스킬을 설치하고 크레딧을 쓰기 전에 확인한 뒤 워크플로를 실행합니다.
[아이디어]에 대한 [길이]초 [세로 / 가로] 영상을 만들어 주세요. 스타일: [스타일]. 대상: [대상]. 오디오: [없음 / ./audio.mp3의 라이선스 트랙 / TTS]. 당신은 터미널이 있고 [WorkBuddy / 코드 실행이 되는 ChatGPT / Codex / Pi Agent / Claude Code]를 실행할 수 있는 폴더에서 작업합니다. Node와 헤드리스 브라우저를 실행할 수 없다면 렌더링한 척하지 말고 멈춰서 저에게 알려 주세요. 다음 순서로 진행하세요. 1. 정확한 시작 초와 종료 초가 있는 3–6비트 스토리보드를 작성하세요. 2. HTML 단계에서는 Felo API를 통해 Claude Opus 5.5를 호출하세요. 모델 ID claude-opus-5-5와 제 FELO_API_KEY를 사용하세요. Claude 구독이나 다른 Anthropic 키를 요구하지 마세요. 3. Opus에게 단일 파일, 자체 완결형 HTML을 요청하세요. window.DURATION을 설정하고 async window.seek(t)를 노출해야 합니다. 외부 자산, CDN, Date.now(), requestAnimationFrame(), CSS 전환, 타이머는 금지입니다. 모든 무작위 값을 시드해 같은 t가 항상 같은 프레임을 그리게 하세요. 4. t = 0, 25%, 50%, 75%, 마지막 프레임에서 미리 보세요. 전체 렌더링 전에 오버플로, 잘림, 폰트, 안전 영역을 확인하세요. 5. Playwright로 모든 프레임을 렌더링하고 ffmpeg로 H.264로 인코딩하세요. 오디오 트랙이 있으면 먹싱하세요. 6. 첫 프레임, 마지막 프레임, 최종 파일 경로를 보여 주세요. 게시하지 마세요. 규칙: - 크레딧을 쓰기 전에 Felo API 호출과 예상 토큰 사용량을 보여 주세요. - 제가 준 카피만 사용하거나, 지어낸 자리표시자 카피임을 분명히 표시하세요. - 저작권이 있는 영상, 음악, 로고, 폰트를 사용하지 마세요. 시스템 폰트와 생성 도형만 사용하세요. - 제가 장편을 요청하지 않는 한 최종 영상은 30초 이내로 유지하세요. - 모든 것을 ./video/에 쓰고 실행한 명령을 요약해 주세요.
복사 내용에는 설치 단계와 안전 규칙도 포함됩니다.
완성되는 것
실행 시 예제 출력입니다. 실시간 생성이 아닙니다.
렌더 보고서
장면: video/scene.html (18 KB) 길이: 12초 · 1080x1920 · 30fps · 360프레임 검사: window.DURATION = 12 · seek(t) 존재 · Date.now / rAF / 타이머 없음
미리보기: preview-0ms.png · preview-3000ms.png · preview-6000ms.png · preview-9000ms.png 인코딩: video/video.mp4 (H.264, 360프레임) 오디오: video/final.mp4 (AAC, 12.0초)
실제 호출
개발자용: 같은 워크플로를 실행 순서대로 담은 복사 가능한 코드.
실제 API 호출 보기
먼저 비트 시트 쓰기
길이와 3~6개 비트를 먼저 고정하고 각 비트의 시작·종료 시간, 화면 카피, 주요 시각 변화를 씁니다.
Opus 5.5로 HTML 장면 생성하기
Anthropic SDK base URL을 Felo로 향하게 하고 모델
claude-opus-5-5를 쓰며 HTML만 반환하도록 요청합니다.window.DURATION과async seek(t)를 포함합니다.generate-scene.ts import { mkdir, writeFile } from "node:fs/promises"; import Anthropic from "@anthropic-ai/sdk"; const SYSTEM = `You write a single, self-contained HTML video scene. Requirements: - 1080x1920 (or 1920x1080), no external assets, system fonts only. - set window.DURATION = <seconds>. - expose async window.seek(t) that draws the frame for time t. - no Date.now(), no requestAnimationFrame(), no CSS transitions, no timers. - seed every random value; the same t must always produce the same frame. - return only the HTML file, no markdown fences.`; const brief = `12-second vertical motion graphic about [idea]. Beats: [0-3s hook], [3-7s point], [7-10s proof], [10-12s CTA]. Palette: [colors]. Type: system sans. Keep text inside a 64px safe area.`; const anthropic = new Anthropic({ apiKey: process.env.FELO_API_KEY, baseURL: "https://openapi.felo.ai/api", }); const message = await anthropic.messages.create({ model: "claude-opus-5-5", max_tokens: 32000, system: SYSTEM, messages: [{ role: "user", content: brief }], }); const raw = message.content .filter((block) => block.type === "text") .map((block) => block.text) .join(""); const html = raw.replace(/^```(?:html)?\s*/i, "").replace(/```\s*$/, "").trim(); await mkdir("video", { recursive: true }); await writeFile("video/scene.html", html); console.log(`scene.html: ${html.length} bytes`);예제 출력
scene.html: 18432 bytes
결정론 확인과 키프레임 미리보기
t=0, 25%, 50%, 75%, 마지막 프레임에서 스크린샷을 찍어 텍스트 넘침, 폰트, 안전 영역을 확인합니다.
preview.ts import { chromium } from "playwright"; import { pathToFileURL } from "node:url"; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1080, height: 1920 }, deviceScaleFactor: 1, }); await page.goto(pathToFileURL("video/scene.html").href); await page.waitForFunction(() => typeof (window as any).seek === "function"); const duration = await page.evaluate(() => (window as any).DURATION as number); for (const t of [0, duration * 0.25, duration * 0.5, duration * 0.75]) { await page.evaluate(async (time) => { await (window as any).seek(time); }, t); await page.screenshot({ path: `video/preview-${Math.round(t * 1000)}ms.png` }); } await browser.close(); console.log(`duration ${duration}s`);예제 출력
duration 12s video/preview-0ms.png video/preview-3000ms.png video/preview-6000ms.png video/preview-9000ms.png
모든 프레임 렌더링과 인코딩
t = frame / fps로seek(t)를 호출해 스크린샷을 찍고 ffmpeg로 H.264 인코딩합니다.render.ts import { spawn } from "node:child_process"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { chromium } from "playwright"; const FPS = 30; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1080, height: 1920 }, deviceScaleFactor: 1, }); await page.goto(pathToFileURL("video/scene.html").href); await page.waitForFunction(() => typeof (window as any).seek === "function"); const duration = await page.evaluate(() => (window as any).DURATION as number); const frames = Math.ceil(duration * FPS); await rm("video/frames", { recursive: true, force: true }); await mkdir("video/frames", { recursive: true }); for (let frame = 0; frame < frames; frame++) { await page.evaluate(async (time) => { await (window as any).seek(time); }, frame / FPS); await page.screenshot({ path: `video/frames/${String(frame).padStart(5, "0")}.png`, }); } await browser.close(); const ffmpeg = spawn("ffmpeg", [ "-y", "-framerate", String(FPS), "-i", "video/frames/%05d.png", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "video/video.mp4", ], { stdio: "inherit" }); await new Promise<void>((resolve, reject) => { ffmpeg.on("exit", (code) => { if (code === 0) resolve(); else reject(new Error(`ffmpeg exited with ${code}`)); }); }); console.log(`${frames} frames -> video/video.mp4`);예제 출력
360 frames -> video/video.mp4
오디오 mux와 최종 확인
-c:v copy로 라이선스 오디오를 넣고 길이와 음량을 확인한 뒤 처음부터 끝까지 봅니다.mux.sh ffmpeg -i video/video.mp4 -i video/audio.wav \ -c:v copy -c:a aac -b:a 192k -shortest \ video/final.mp4 ffprobe -v error -show_entries format=duration \ -of default=nw=1:nk=1 video/final.mp4예제 출력
12.000000
같은 모델로 반복하기
잘못된 프레임과 원하는 변경만 설명하고 현재 HTML과 함께 Opus에 넘깁니다. 미리보기를 다시 실행한 뒤 전체 렌더링합니다.
포함되는 것
- 먼저 쓰는 스토리보드와 타임라인
- Opus 5.5가 만든 결정론적 HTML 장면
- Playwright 프레임 렌더 스크립트와 ffmpeg 인코딩
- 선택적 오디오 트랙이 있는 최종 MP4
- 프레임 단위 리뷰와 수정 루프
하지 않는 것
- Opus 5.5는 코드를 출력하지 영상 프레임을 만들지 않음. 실제 캡처는 브라우저 렌더러가 함
- Claude 구독은 필요 없지만 Felo 모델 사용은 사용량 기반
- 실행 환경에 Node, Playwright Chromium, ffmpeg 필요
- 외부 소재와 CDN 불가. 시각은 HTML/CSS/SVG/Canvas 또는 로컬 파일만
- 긴 영상은 장면 분할 필요. 실시간 화면 녹화가 아님
- 나레이션과 음악은 별도 단계. 워크플로는 제공된 오디오만 mux
자주 묻는 질문
Opus 5.5가 직접 영상을 생성하나요?
아니요. 텍스트와 코드를 출력해 HTML 장면을 씁니다. 브라우저와 ffmpeg가 장면을 프레임과 MP4로 바꿉니다.
Claude 구독이 필요한가요?
필요 없습니다. Felo 키로 Felo API를 통해 `claude-opus-5-5`를 호출하고 사용량 기반으로 씁니다. 별도 Claude 구독은 필요 없습니다.
어떤 agent에서 실행할 수 있나요?
WorkBuddy, 코드 실행이 되는 ChatGPT, Codex, Pi Agent, Claude Code 또는 Node와 headless 브라우저를 실행할 수 있는 agent.
왜 장면에 seek(t)가 필요한가요?
프레임 캡처는 실시간 재생이 아닙니다. 399번 프레임을 찍고 400번을 찍기까지 시간이 걸릴 수 있어 모든 프레임이 t의 순수 함수여야 합니다.
HyperFrames나 Remotion을 쓸 수 있나요?
네. 긴 합성이나 구조화된 프로젝트에 적합합니다. 이 레시피는 단일 HTML 파일로 유지해 모델 호출과 렌더 루프를 확인하기 쉽게 합니다.
12초 영상 비용은?
프롬프트, 출력 토큰, 반복 횟수에 따라 다릅니다. Felo 모델은 토큰 과금이며 긴 렌더링 전에 실시간 가격을 확인하세요.
내 음악과 나레이션을 쓸 수 있나요?
권리가 있을 때만 가능합니다. 먼저 영상을 렌더링하고 ffmpeg로 라이선스 오디오나 직접 만든 TTS를 mux하세요. 워크플로는 나레이션을 생성하지 않습니다.
참고 자료
이 워크플로가 참고한 튜토리얼과 프로젝트.
관련 워크플로
리서치 페이지를 슬라이드 덱으로 바꾸기
페이지를 추출해 LiveDoc 맥락으로 저장하고 PPT Task API가 deck을 만드는 동안 완료 링크를 폴링합니다.
워크플로 열기콘텐츠제품 브리프를 랜딩 페이지로 바꾸기
청중을 조사하고 브리프를 LiveDoc에 저장한 뒤 그 맥락으로 Landing Page 작업을 만들고 호스팅 페이지가 준비될 때까지 폴링합니다.
워크플로 열기콘텐츠청중이 실제로 묻는 질문으로 한 달 콘텐츠 채우기
Chat으로 주제를 정리하고, 청중이 X에서 묻는 질문을 찾고, 참고 영상 자막에서 실제 각도를 뽑아 모든 행에 증거를 적은 4주 콘텐츠 캘린더를 만듭니다.
워크플로 열기내 방식으로 바꾸기
같은 레시피를 실제 배포 방식에 맞게 조정합니다.
HyperFrames나 Remotion 프로젝트로 바꾸기
긴 합성이나 구조화된 작업에는 이 프레임워크를 씁니다. 모델 호출은 같고 렌더 명령만 바뀝니다.
정사각형 소셜 버전 만들기
비트 시트를 유지하고 1080x1080으로 바꿔 글자 크기와 안전 영역을 조정합니다.
한 브리프에서 세 가지 버전 만들기
같은 브리프로 색과 리듬이 다른 장면 세 개를 만들고 미리본 뒤 가장 좋은 하나만 렌더링합니다.
더 알아보기
이 레시피의 배경이 되는 참고 페이지와 다음에 볼 곳.