주식 수익률 변동을 애니메이션 영상으로 만들어 YouTube Shorts로 발행하는 개인용 로컬 툴.
티커 몇 개와 기간을 넣으면 "2020년, 1,000만원을 넣었다면?" 같은 세로형(1080×1920, 60fps, 약 40초) 라인 레이스 영상이 나온다. 훅 → 레이스(₩ 환산 카운터·종목 간 격차·완급 연출) → 결과 요약 정지 프레임.
- 한국/미국 주식·ETF 지원 (지수는 라이선스 문제로 추종 ETF 사용: S&P500→SPY, 나스닥100→QQQ)
- 수정주가(adjusted close) 기준 가격수익률 — 화면에 명시
- 한/미 휴장일 차이는 union 거래일 축 + forward-fill로 정렬, 리베이스 100
컴포넌트는 기능이 아니라 계약 3개로 자른다. 계약이 안정되면 양쪽 구현은 독립적으로 교체 가능하다.
flowchart LR
ADP["Python collector<br/>KR·US·FX 어댑터"] -->|"계약 ① 쓰기"| DB[("SQLite<br/>일간 OHLCV·환율")]
DB -->|"읽기 전용"| SB["spec-builder<br/>정규화·리베이스·다운샘플"]
SB -->|"계약 ② Scene Spec JSON"| TPL["templates<br/>(frame, spec) → 화면 상태<br/>순수 함수"]
TPL -->|"계약 ③"| DRAW["renderer-canvas<br/>단일 drawScene"]
DRAW --> REM["Remotion 셸<br/>→ mp4 (+BGM 먹싱)"]
DRAW --> WEB["웹 스튜디오<br/>실시간 미리보기"]
- 계약 ① SQLite — Python(collector)은 쓰기만, TypeScript는 읽기만. 언어 경계가 DB 파일 하나다.
- 계약 ② Scene Spec —
specVersion·template·공유 time 배열을 가진 JSON. 렌더러와의 유일한 계약. - 계약 ③ 템플릿 인터페이스 — 연출 로직(easing·완급·카운터)은 전부 templates의 순수 함수에 있고,
Remotion(mp4)과 웹 미리보기는 같은
drawScene(Canvas 2D) 하나를 공유한다. 미리보기에서 본 것과 영상이 어긋날 수 없는 구조.
| 경로 | 역할 |
|---|---|
data/collector/ |
Python — 시장 어댑터(FDR 1차, yfinance 폴백) + SQLite 증분 적재 |
packages/scene-spec |
계약 ②: zod 스키마 + 검증기 |
packages/spec-builder |
정규화 → Scene Spec 생성 (순수 함수, I/O 없음) |
packages/templates |
계약 ③: 템플릿 = 프레임 → 화면 상태 |
packages/renderer-canvas |
단일 페인터 drawScene + 실시간 플레이어 |
packages/renderer-remotion |
Remotion 셸 (drawScene을 감싸 mp4로) |
apps/server |
로컬 API(:4600) — Spec 생성, 렌더 잡 큐, BGM 목록 |
apps/worker |
잡 큐 소비 → Remotion 렌더 → ffmpeg BGM 먹싱 |
apps/web |
제작 스튜디오(:5173) — 입력 폼 + 실시간 미리보기 |
요구사항: Node 22+, pnpm 10(corepack), Python 3.11+, ffmpeg
pnpm install
python3 -m venv .venv
.venv/bin/pip install finance-datareader yfinance pandas
.venv/bin/python3 scripts/make-placeholder-bgm.py # (선택) 검증용 BGM 트랙 생성pnpm server # API 서버 :4600
pnpm queue # 렌더 워커 (잡 큐 폴링)
pnpm web # 웹 스튜디오 :5173 (/api는 서버로 프록시)http://localhost:5173 에서 종목·기간·타이틀·맥락 한 줄을 넣고 미리보기 → 렌더 (mp4).
부족한 시세 데이터는 서버가 collector를 호출해 자동 적재한다.
렌더 결과는 out/job_<id>.mp4에 쌓이고, 스튜디오의 "mp4 다운로드" 링크로 받는다.
첫 렌더 때는 Remotion이 Chrome Headless Shell을 내려받으므로 한 번은 오래 걸린다.
docker compose up # server(:4600, 웹 포함) + workerdata/(시세·잡 DB), out/(결과물), assets/(BGM)는 호스트 볼륨으로 공유한다.
worker 이미지는 Chrome Headless Shell 런타임 라이브러리·Noto CJK 폰트·ffmpeg를 포함해 server보다 크다.
.venv/bin/python3 data/collector/ingest.py 005930 NVDA USD/KRW # 시세 적재 (증분)
.venv/bin/python3 data/collector/ingest.py NVDA --start 2010-01-01 # 과거 구간 백필
.venv/bin/python3 data/collector/ingest.py 005930 --full # 전체 재수집
pnpm render specs/golden_synthetic.json [out.mp4] # Spec → mp4 (기본 out/<spec>_remotion.mp4)
pnpm studio # Remotion Studio
pnpm --filter @hindsight/worker exec tsx src/still.ts specs/golden_synthetic.json 600 "$PWD/out/frame600.png" # 특정 프레임 PNG증분 모드는 캐시의 최신일 − 7일부터만 다시 받는다. --start가 지금까지 요청해 본 범위(ingest_meta.covered_from)보다
앞서면 그 날부터 다시 받아 과거를 채운다(백필). 상장일 이전을 요청해도 한 번만 시도하고 기억한다.
액면분할·배당락 뒤 수정주가를 갱신하려면 --full(--start 기본 2010-01-01)로 전체를 다시 받는다.
웹 UI 없이 서버 API만으로도 Spec을 만들 수 있다 (pnpm server가 떠 있어야 한다):
curl -s localhost:4600/api/specs -H 'content-type: application/json' -d '{
"tickers": [{"id": "005930", "name": "삼성전자"}, {"id": "SPY", "name": "S&P 500"}],
"start": "2020-01-01", "end": "2024-12-31",
"title": "2020년, 1,000만원을 넣었다면?",
"contextLine": "국민주와 미국 시장 — 같은 5년, 다른 결말",
"seedKrw": 10000000
}' > specs/samsung_spy.json
pnpm render specs/samsung_spy.json| 형태 | 분류 | 소스 | 예 |
|---|---|---|---|
| 숫자 6자리 | 한국 주식·ETF | FinanceDataReader(네이버) — Close가 이미 수정주가 | 005930, 069500 |
| 그 외 영문 | 미국 주식·ETF | FinanceDataReader(야후 Adj Close) → 실패 시 yfinance | NVDA, SPY, QQQ |
A/B |
환율 | FinanceDataReader | USD/KRW |
표시 이름(name)을 주면 화면에는 그 이름이 나온다. 한국 종목 코드는 라인 헤드 배지에도 이름을 쓴다.
웹 스튜디오는 종목을 1~5개까지 받는다. 기간 중간에 상장한 종목이 섞이면 모든 종목에 값이 생기는
첫 날부터 리베이스한다.
계약 ②. 데이터와 연출 파라미터를 담은 JSON 한 장이며, 렌더러는 이것만 읽는다.
스키마는 packages/scene-spec의 zod가 단일 소스다.
{
"specVersion": 1,
"template": "line-race",
"targetFps": 60,
"maxPoints": 600,
"timeline": { "hookSec": 1.5, "raceSec": 35.5, "endSec": 3.0, "warp": 0.6 },
"meta": {
"title": "2020년, 1,000만원을 넣었다면?",
"contextLine": "국민주와 미국 시장 — 같은 5년, 다른 결말",
"returnType": "price",
"seedKrw": 10000000,
"dataSource": "FinanceDataReader"
},
"bgm": { "track": "placeholder_ambient.m4a", "gainDb": -6 },
"axis": { "time": ["2020-01-02", "2020-01-06", "..."] },
"series": [
{ "id": "005930", "name": "삼성전자", "color": "#76B900", "values": [100, 101.2, "..."] },
{ "id": "SPY", "name": "S&P 500", "color": "#5B8DEF", "values": [100, 99.4, "..."] }
]
}| 필드 | 설명 |
|---|---|
timeline |
훅 + 레이스 + 엔딩(초). 합이 영상 길이. warp 0=선형, 1=ease-out(초반 빠르고 후반 느림) |
meta.contextLine |
필수. 엔딩 패널 제목이자 유튜브 반복 콘텐츠 정책 대응 |
meta.returnType |
price(가격수익률) / total(총수익률). 푸터에 표기된다 |
meta.seedKrw |
선택. 있으면 ₩ 환산 카운터를 그린다 |
bgm |
선택. track은 assets/bgm/의 파일명, gainDb 기본 −6 |
axis.time |
공유 날짜축. 길이 ≤ maxPoints |
series[].values |
리베이스 100 기준. 모든 시리즈가 axis.time과 같은 길이여야 한다 |
| 메서드 | 경로 | 역할 |
|---|---|---|
POST |
/api/specs |
tickers, start, end, title, contextLine, seedKrw?, targetFps?, maxPoints?, bgm? → Scene Spec. 부족한 시세는 자동 적재 |
POST |
/api/renders |
{ spec } → { id }. 렌더 잡을 큐에 넣는다 |
GET |
/api/renders/:id |
{ status, error, hasFile }. status는 queued / running / done / error |
GET |
/api/renders/:id/file |
완료된 mp4 다운로드 |
GET |
/api/bgm |
assets/bgm/의 트랙 목록 |
환경 변수: PORT(기본 4600), PYTHON(collector를 실행할 파이썬, 기본 .venv/bin/python3).
assets/bgm/에 음원 파일(mp3/m4a/wav)을 넣으면 웹 스튜디오 드롭다운에 나타난다.
워커가 렌더 후 ffmpeg로 루프·페이드(인 0.8s / 아웃 2.5s) 먹싱한다.
발행용 음원은 YouTube 오디오 보관함(수익화 안전) 사용을 권장하며, 음원은 저장소에 커밋하지 않는다.
- 테스트 러너·린터는 없다. 타입체크는
pnpm -r exec tsc --noEmit. - 스모크 테스트는
pnpm render specs/golden_synthetic.json. 시드 고정 합성 랜덤워크라 실제 시세 없이 파이프라인 전체를 돈다. 재생성:.venv/bin/python3 scripts/make-synthetic-fixture.py - 연출을 바꿨으면
still.ts로 프레임을 뽑아 눈으로 확인한다. 연출 로직은packages/templates, 픽셀 그리기는packages/renderer-canvas/src/draw.ts에만 있다. - 워커는 Remotion 번들을 프로세스당 한 번만 만든다. packages를 고쳤으면
pnpm queue를 재시작한다. - 템플릿 추가 절차와 계약별 불변 조건은
CLAUDE.md에 있다.
| 증상 | 확인할 것 |
|---|---|
렌더 잡이 queued에서 안 움직임 |
pnpm queue(또는 docker worker)가 떠 있는지 |
해당 기간 데이터 없음 |
티커 표기·상장일 확인. ingest.py <티커> --start <날짜>로 수동 적재 |
| 수익률이 이상함(분할·배당락 의심) | ingest.py <티커> --full로 재수집. 수정주가는 과거 전체가 바뀐다 |
| 리눅스에서 한글이 네모로 나옴 | Noto CJK 등 한글 폰트 설치 (docker worker 이미지에는 포함) |
| BGM 먹싱 실패 | ffmpeg가 PATH에 있는지, 트랙 파일이 assets/bgm/에 있는지 |
| 단계 | 범위 | 상태 |
|---|---|---|
| P0 | matplotlib PoC — 연출 감각 확보 | ✅ |
| P1 | Scene Spec v1 + Remotion 파이프라인 | ✅ |
| P2 | collector + 웹 스튜디오 + 잡 큐 + docker | ✅ |
| P3 | 템플릿 3종(단일/라인 레이스/바 차트 레이스) + 메타 생성 | 예정 |
| P4 | YouTube 업로드 자동화 (OAuth 검증 통과 후) | 예정 |
| P5 | 모바일 뷰어 앱 + 공개 뷰어 API | 선택 |
설계 결정 기록(ADR)·단계별 실행 계획은 비공개 노션에서 관리한다.
- 시세 데이터는 FinanceDataReader/yfinance 등 무료·비공식 소스다. 수익화 규모가 커지면 상업 이용이 허용된 유료 소스로 전환한다 (어댑터 뒤에 숨겨둔 이유).
- Remotion은 개인·직원 3인 이하 조직은 상업 이용 포함 무료. 메이저 버전 업그레이드 시 라이선스 재확인.
- YouTube는 반복적·대량생산 콘텐츠를 수익화 불가로 분류한다 — 영상마다 맥락 한 줄(
contextLine)이 계약 수준에서 필수인 이유. - 실제 시세로 만든
specs/*.json·out/*.mp4·data/*.db는 커밋하지 않는다(gitignore). 저장소에 들어가는 Spec은 합성 픽스처specs/golden_synthetic.json하나뿐이다.
MIT — LICENSE 참고.