Habitmon은 제가 제작하고 있는 습관 추적기입니다. 한 번의 터치로 체크인하고, 스트릭(streaks), 연간 기록, 차트에서 패턴을 확인할 수 있습니다. 이 앱에 런칭 영상이 필요했습니다. 저는 After Effects를 모릅니다. 하지만 React는 알고 있습니다.
그래서 저는 앱을 만드는 방식대로 영상을 만들었습니다. 코드로요. Claude Code가 타이핑의 대부분을 담당하게 했습니다. 빈 프로젝트 상태에서 최종 파일까지 약 14시간, 커밋은 19개, 그리고 완전히 승인하지 않은 AI 비평가로부터 7차례의 검토를 거쳤습니다. 파이프라인이 어떻게 작동하는지, Claude가 무엇에 강했는지, 그리고 제가 어디에 개입해야 했는지를 소개합니다. 전체 설정은 오픈 소스입니다: claude-video-studio.
요약 (TL;DR)
- 이 영상은 Remotion 프로젝트입니다. React 컴포넌트를 MP4로 렌더링합니다. 30초 분량이며, 60fps, 비율은 9:16과 16:9를 지원합니다.
- Claude Code는 9단계의 파이프라인을 실행했으며, 제가 승인해야 하는 7개의 게이트에서 멈췄습니다.
- 음악이 가장 먼저 결정되었습니다. 모든 컷, 애니메이션 및 사운드 효과는 프레임 단위가 아닌 **비트(beats)**에 맞춰 배치됩니다.
- 영상 속 UI는 앱의 실제 UI이며, 영상 프로젝트로 포팅되어 데모 계정으로 데이터를 공급받습니다.
- 별도의 비평가 서브 에이전트가 코드 자체가 아닌 렌더링된 프레임만을 가지고 모든 초안을 평가했습니다.
설정 (The setup)
| 작업 | 도구 |
|---|
| 그림 (Picture) | Remotion 4 (React 19), 차트용 Recharts (앱과 동일한 라이브러리) |
| ... | |
- Claude는 소리를 듣거나 실시간 비디오를 시청할 수 없습니다. 템포(tempo), 음량(loudness) 및 클리핑(clipping)은 측정할 수 있습니다. 하지만 목소리가 따뜻하게 들리는지, 혹은 컷 전환이 늦게 느껴지는지를 판단할 수는 없습니다. 그런 호출은 저의 영역입니다.
- 저렴한 결정부터 합니다. 스크립트 라인 하나를 변경하는 것은 비용이 들지 않습니다. 60 fps로 최종 결과물을 재렌더링하는 것은 40분이 걸립니다.
하우스 규칙(house rules)은 CLAUDE.md에 있습니다: 렌더 계약("모든 프레임은 프레임 번호의 순수 함수여야 함": CSS 트랜지션, Math.random, Date 사용 금지), 금지된 비주얼 목록 (그라데이션 위의 중앙 제목, 크로스페이드, 글로우(glow), 파티클 버스트, 바운스 효과 등), 그리고 TikTok/Reels/Shorts가 자체 버튼을 배치하는 9:16 안전 구역이 포함됩니다.
Step 1: 주장을 검토하는 브리프
단 하나의 프레임도 만들기 전에, Claude는 앱의 코드를 읽고 Habitmon이 실제로 할 수 있는 것들을 목록화했으며, 각 주장마다 이를 증명하는 파일을 첨부했습니다. 여기서 무언가를 발견했습니다: 마케팅 사이트는 "용서해주는 연속 기록(forgiving streaks)", 마일스톤 알림, 공유 가능한 진행 카드 등을 약속했지만, 이 중 어떤 것도 코드에는 존재하지 않습니다 (연속 기록 쿼리는 엄격하게 연속된 날짜만 계산합니다). 따라서 비디오에도 이러한 요소는 포함되지 않았고, 비평가는 주장이 목록에 없으면 브랜드 점수를 최대 4점으로 제한했습니다.
Step 2: 비트에 맞는 스크립트 구성
스크립트는 표 형태입니다: 샷(shot)마다 하나의 음성 대사 라인을 배치하고, 각 라인은 비트(beat)에 고정됩니다. 총 8줄이며, 30초 동안 48개의 단어입니다. 이는 화자의 자연스러운 속도인 초당 약 2.3단어보다 느리므로, 음악과 로고를 위한 여유 공간이 생깁니다. Claude는 두 가지 후크 옵션("오늘 나타났나요?"와 "나타났습니다?")을 작성했고, 저는 전자를 선택했습니다. 이는 개인적이며, 화면 터치만으로 2초 안에 답할 수 있기 때문입니다. 화면에 표시되는 텍스트는 항상 말하는 단어이거나 더 짧은 캡션이어야 하며, 절대로 의역해서는 안 됩니다. 왜냐하면 많은 사람들이 소리 없이 시청하기 때문입니다.
Step 3: 오디오 우선
Diagram: music from ACE-Step and voice from ElevenLabs feed timeline.json, which holds marks in beats; it drives the picture through useTimeline() and the sound through sfx.py and mix.py
대부분의 편집에서는 음악이 마지막에 들어갑니다. 여기서는 음악이 먼저 들어가는데, 그 이유는 음악의 비트 그리드가 영화의 시계 역할을 하기 때문입니다.
- 음악. ACE-Step이 로컬에서 3개의 후보 트랙을 생성했습니다. 스크립트가 각 트랙의 템포와 비트가 그리드 위에 얼마나 밀접하게 놓여 있는지 측정했고, 선택된 곡은 드리프트(drift)가 15ms 미만인 120 BPM으로 실행됩니다.
- 비트 기반 타임라인.
timeline.json 파일에는 이름이 지정된 마크(예: "tap": 6, "chart": 36), 마크 사이의 샷, 그리고 마크에 연결된 음성 라인 및 사운드 효과가 포함되어 있습니다. 이 파일을 변경할 때마다 후크(hook)는 이를 초 단위로 재해결합니다.
- 장면은 "어떤 비트?"를 묻습니다.
useTimeline() 함수는 마크를 현재 프레임 속도에 맞는 프레임으로 변환하여, 30 fps 초안과 60 fps 최종본이 하나의 타임라인을 공유하게 하고, 한 마크를 움직이면 그것에 연결된 모든 요소가 함께 움직이게 합니다.
- 음성. 가장 어려운 라인에 대해 ElevenLabs 음성 세 가지를 오디션했습니다. 저는 그중 하나를 선택했고, 이후 타임스탬프와 함께 8개의 라인이 생성되었습니다. 두 개의 라인은 할당된 시간을 초과했기 때문에, 마크는 단어가 실제로 발화되는 위치로 이동했습니다. 로고는 "Habitmon"이라는 단어와 같은 비트에 맞춰 배치됩니다.
- 사운드 효과. 팝(pops), 틱(ticks), 우쉬(whooshes), 라이저(riser), 그리고 스텀프(thump) 등 총 32가지 사운드가 모두 Python으로 합성되었습니다. 샘플이나 트랙에 대한 라이선스가 필요 없습니다.
- 믹싱. 음악은 모든 단어 아래에서 약 9dB만큼 낮춰졌고(ducking), 유튜브가 정규화하는 레벨인 −14 LUFS로 마스터링되었습니다.
이 시계 위에서 영화 전체를 확인해 보세요:
A beat map of the 30-second film: six shot blocks, eight voice-line blocks, 32 sound-effect markers and the music's level over 60 beats. A dashed line at beat 49 marks the logo landing with a riser, a thump and the word
단계 4~6: 목업이 아닌 실제 UI 구현 (real UI, not mockups)
하나의 규칙이 있습니다. UI를 절대 지어내지 않는 것입니다. Claude는 앱 자체의 컴포넌트(캘린더, 습관 목록, 통계 카드 및 네 개의 차트)를 비디오 프로젝트로 포팅했으며, 시드된 데모 계정의 정적 데이터를 사용했습니다. 화면에 표시되는 숫자는 해당 데모 계정의 데이터입니다.
스토리보드도 스케치로 만든 것은 아니었다. 각 샷의 핵심 포즈는 실제 장면 코드로 구축되어 스틸 이미지로 렌더링되었기 때문에, 나는 영화가 실제로 어떻게 보일지 승인할 수 있었다. 나의 첫 번째 피드백은 직설적이었다: 차트를 더 많이 보여주고, 제목 글꼴을 바꾸고, 엔딩 카드가 보기 안 좋으며, 보라색 배경이 사방에 있다. 수정본 2에서는 이 네 가지를 모두 고쳤다: Inter Tight 타이틀, 모든 차트 포함, 후크 부분의 배경만 사용하고 나머지는 제거했으며, 앱 자체의 헤더 바가 프레임을 가득 채우도록 성장하는 엔딩 카드를 만들었다.
The one-tap moment in 16:9: the habit list and a full-year calendar, with
두 형식 모두 동일한 타임라인에서 나오지만, 각각 고유의 레이아웃을 갖는다. 9:16 버전은 압축된 데스크톱 대시보드 대신 앱의 실제 휴대폰 레이아웃을 사용한다:
Three moments (hook, streak, insights), each shown in 16:9 above and 9:16 below, with different layouts at the same beat
7~9단계: 비평가, 휴대폰 확인, 최종본
The critique loop: render a draft, build a review kit, frame-critic scores it, fix the worst 3 and commit, repeat. A side panel lists the hard caps, such as a fade used as a transition scoring at most 6
이것은 어떤 프로젝트에서든 재사용할 수 있는 부분이다. 초안 렌더링 후마다, 스크립트가 검토 키트(review kit)를 구축한다:
- 초당 2프레임의 컨택 시트(contact sheets)
- 폭이 약 휴대폰 크기인 전체 영화 영상 (360px 너비)
- 점프나 끊김 현상이 숨어있을 수 있는 모든 컷 주변의 연속된 12개 프레임 스트립
- 플랫폼 버튼 영역에 빨간색 오버레이가 된 9:16 버전
- 측정 지표(metrics): 빈 프레임, 가장 긴 정지 유지 시간, 사운드가 시각적 요소와 일치하는지 여부, 음량
별도의 서브 에이전트인 frame-critic은 해당 키트를 검토합니다. 이의 프롬프트는 “당신은 자신이 만들지 않은 영상을 검토하고 있다”로 시작합니다. 따라서 코드를 보지 않고, 시청자가 무엇을 보는지에 따라 판단합니다. 8가지 기준(criteria)으로 점수를 매기며, 모든 점수에 대한 증거로 타임코드(timecode)가 필요합니다 (“증거 없이 8점은 없다”). 또한 관대할 수 없도록 하드캡(hard caps)이 있어, 전환 효과로 사용된 페이드(fade)는 아무리 좋아도 6점을 넘을 수 없습니다.
매 라운드마다 Claude는 가장 나쁜 문제 세 가지만 수정하고 커밋(commit)한 다음 다시 렌더링합니다.
Heatmap of the critic's scores over 7 rounds: hook and brand at 8 throughout, readability and sound sync reaching 8 by round 4, pacing by round 7, polish at 6 then 7, motion and composition at 6 in the final round
비평가는 모든 점수를 8점으로 원했습니다. 7라운드가 지난 후에도 모션(motion)과 구성(composition)은 여전히 6점에 머물렀습니다. 남은 지적 사항들은 “제목이 전체 마스크 리프트 대신 불투명도 하락으로 끝난다”거나 “0.23초 동안의 빈 패널 밴드(bare panel band)”와 같은 것이었습니다. 저는 두 초안을 휴대폰으로, 한 번은 사운드와 함께, 한 번은 음소거 상태로 시청한 후, 괜찮다고 판단하고 배포했습니다. 비평가의 임무는 문제를 찾는 것입니다. 어떤 문제가 중요한지 결정하는 것은 여전히 저의 몫입니다.
그제야 최종 단계가 찾아왔습니다: 모션 블러(motion blur)가 적용된 60 fps로, 포맷당 약 19분의 렌더링 시간이 소요되었고, 마스터링된 믹스(mastered mix)가 추가되었으며, 음성 타임스탬프를 기반으로 자막이 작성되었습니다.
The charts sequence: a progress chart draws in with a dip in March, then the camera moves to the radar, gap and recovery charts
알아두면 좋은 세 가지 버그
Recharts 축 레이블 깜빡임 현상(flickered in the render). 애니메이션을 끄더라도 축 레이블이 여전히 두 크기 사이를 프레임 단위로 점프했습니다. Remotion은 여러 브라우저 탭에서 동시에 프레임을 렌더링하며, Recharts는 마운트(mount)당 측정된 레이아웃 상태를 유지하기 때문에 각 탭마다 다르게 측정되었습니다. 해결책은 모든 프레임마다 차트를 다시 마운트(remount)하는 것입니다:
const frame = useCurrentFrame();
<AreaChart key={frame} data={data} width={w} height={h}>
...