← 영상
영상
변경 안내
코드 렌더 · 같은 결과
영업시간·쉬는 날·가격처럼 바뀌는 것을 장부에 고쳐 적듯 하나씩 전→후로 보여 줍니다.
계기업데이트
가상 브랜드 온기 베이커리로 레시피를 실제 실행해 렌더한 영상입니다. 소리는 없습니다.
레시피 보기106,438자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 영상 「변경 안내」 9:16 · 컷 5개 · 약 12초 (표지 → 바뀌는 것 하나씩 전→후 → 모아 보기)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. **AI 영상 생성을 쓰지 않는다.** 판도 필요 없다 — 장부 종이·괘선·글자·움직임을 코드(React + HyperFrames)가 실제 폰트로 그려 세로 MP4 한 편으로 렌더한다.
- 9:16 1080×1920 · 30fps · 컷 5개(표지 1 + 항목 1~3 + 모아 보기 1 = 3~5개, 합계 8~12초, 컷마다 1.8~4초) · **무음**(음악은 편집 앱에서)
- **장부 체계**(update-change-list 와 같은 서체): 종이 장부(옅은 가로 괘선 + 왼쪽 포인트색 여백선), 위 머리줄(브랜드 · 바뀌는 날).
- 1 **표지**: 초대형 날짜(Abril Fatface — 깃 달린 `1` 이라 `11.03` 이 `II.03` 으로 읽히지 않는다, 숫자 사이 자간 조금 넓게, 포인트색)가 0프레임에 1.35배에서 **찍히듯** 내려앉고 + 제목(Noto Serif KR 700) + 바뀌는 항목 목록이 한 줄씩 적힌다.
- 2~ **항목마다 한 컷**: 큰 번호(01·02·03) + 항목 이름 → "전" 값에 펜 줄이 그어지고 → 화살표가 그려지고 → "후" 값이 아래에서 올라와 → 그 밑에 형광 띠(가장자리가 고르지 않은 형광펜 자국)가 깔린다. **배치가 컷마다 번갈아 바뀐다**: 번호 왼쪽·이름 오른쪽 ↔ 이름 왼쪽·번호 오른쪽, 짧은 후 값("월요일")은 폭을 채우도록 더 크게.
- 마지막 **모아 보기**: 바뀐 값만 모은 목록(체크가 하나씩 그려짐) + 한 줄 + 큰 브랜드명. 브랜드명이 안전 영역 아래 끝에 앉고 목록이 남는 높이를 고르게 나눠 써서 화면 위아래가 고르게 찬다. 단계가 컷 끝까지 이어진다(목록 → 체크 → 한 줄 → 브랜드가 찍히고 천천히 다가옴).
- 바닥 칸(릴스 화면 아래 35%)은 괘선과 큰 번호의 윤곽선(포인트색, 오른쪽 아래로 잘림)이 채운다. 컷 안에서 계속 움직인다: 장부 괘선이 천천히 위로 흐르고, 내용도 조금씩 오르고, 윤곽 번호는 더 빠르게 오른다(시차). 컷이 바뀔 때 앞 내용이 괘선을 따라 위로 밀려 나가며 0.23초 안에 사라지고, **그다음** 새 내용이 아래에서 올라온다(두 컷 글자가 겹쳐 비치는 크로스페이드 없음).
- 코드 검사: 글자 넘침·안전 영역(위 12%·아래 35%·좌우 64px)·대비(작은 글자 4.5, 큰 글자 3, 형광 띠 위 글자 4.5 — 띠 색을 코드가 옅게 맞춤)·노랑 글자·글자끼리 겹침·컷 길이·읽는 시간·**0.3초 훅**(첫 프레임은 완성된 정지 화면이 아니라 찍히듯·들어오듯 등장 중 — 0초와 0.3초 화면 차이를 잰다)·컷끼리 닮음·**전환 겹침**(컷이 다른 글자 둘이 같은 자리에 동시에 보이면 실패 — 나가는 글자가 다 빠진 뒤 들어온다, MP4 모든 프레임에서 잰다)·**그래픽↔글자 겹침**(해·햇살·원·고리·시계·선·윤곽 숫자가 글자 잉크+12px 에 걸리면 실패 — 모든 프레임에서 실제 모양으로 잰다)·**의미 단위 줄바꿈**(수+단위·관형사+명사 사이에서 줄을 바꾸지 않는다)·**컷 안 멈춤**(MP4 를 다시 풀어 0.5초 간격 프레임 차이 — 0.8초 넘게 거의 멈추면 실패)·무음.
원본 템플릿: https://duchamp.app/readymade/videos/change-notice-video
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "밝은 색(종이), 어두운 색(먹), 포인트색" HEX 3개. 포인트색은 날짜·번호·줄 긋기·형광 띠. 노랑이면 글자(날짜·번호)는 먹색으로 바뀐다. 비우면 #F3ECE0, #231F1B, #B4532A
주제: (내 브랜드에 맞는 주제 하나) # 무엇이 바뀌는지. 예: 11월부터 영업시간·휴무·가격 변경
넣을 글자: (주제에 맞춰 분량 상한 안에서 컷별 글자를 지어 넣는다) # 컷마다
# 컷 1 표지: 큰: 11.01 / 줄: 이렇게 달라져요 (날짜 5자 안팎, 제목 12자 안팎)
# 컷 2 항목: 이름: 영업시간 / 전: 오전 8시 – 오후 8시 / 후: 오전 9시 – 오후 9시 (이름 6자·값 12자 안팎)
# 컷 3 항목: 이름: 쉬는 날 / 전: 월요일 / 후: 화요일
# 컷 4 항목: 이름: 우유 식빵 / 전: 4,500원 / 후: 4,800원
# 컷 5 모아 보기: 줄: 늘 같은 자리에서 굽습니다 (선택, 20자 안팎)
# (항목은 1~3개 — 바뀌는 것만. 값은 사용자가 준 그대로)
출력: MP4 # MP4 | 컷 정지 화면만
질감 판: 없음 # 없음 | 자동 | 내 사진: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 브랜드명·글자는 한 글자도 바꾸지 않는다(띄어쓰기·물결표·대시 포함). 시간·요일·가격은 사용자가 준 것만 쓴다 — 주지 않은 사실(이유·기간·사과 문구)을 지어 넣지 않는다.
- "전"·"후" 표시와 번호(01·02·03)는 코드가 넣는다. 모아 보기 컷의 목록은 항목 컷의 이름·후 값을 그대로 다시 쓴다(새 글자를 넣지 않는다).
- 글자가 길어 코드가 실패하면(`item-long`·`date-long`·`read-time`) 줄인 안을 사용자에게 물어본다.
- **AI 영상 생성은 쓰지 않는다.** 이미지→영상·글자→영상 생성 도구(Seedance·Kling·Sora·Veo·Runway·Higgsfield 영상 등)를 부르지 않는다. 움직임(찢김·밀려 들어옴·굴러 올라옴·그려짐·모양 바뀜·흔들림·빛 띠·시차)은 전부 아래 코드가 만든다.
- 음악·효과음·음성은 넣지 않는다. 결과 MP4 는 무음이다(음악은 사용자가 인스타그램·캡컷 같은 편집 앱에서 붙인다 — 보고에 이 한 줄을 쓴다).
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.json`(제품 사진 템플릿은 `confirm.json` 도) 뿐이다. 글자 크기·줄바꿈·자리·대비·움직임·컷 길이 검사는 코드가 재서 정한다. 코드가 실패하면 오류 메시지를 그대로 보고하고, 고쳐야 했다면 무엇을 왜 고쳤는지 보고에 쓴다. 다른 폴더에서 비슷한 코드를 찾아 쓰지 않는다 — 이 문서의 코드만 쓴다.
- 비밀 값·.env 파일은 읽지 않는다.
- 필요한 것: Node 18 이상, 인터넷(`npm install` 때만 — 폰트도 npm 패키지로 받으므로 렌더는 인터넷 없이 된다). 설치는 이 폴더 안에서만(`npm install` — 렌더러 HyperFrames(Apache 2.0)·esbuild·React·ffmpeg). 첫 실행 때 HyperFrames 가 헤드리스 Chrome(약 190MB)을 이 폴더의 `.render/home` 에 받는다. ffmpeg 은 npm 패키지(ffmpeg-static)로 따라오므로 따로 설치하지 않는다(그 설치가 막힌 환경이면 시스템 ffmpeg 를 쓴다).
- 이 작업 밖의 일(프로젝트 관리 카드·메모 갱신, 배포, 다른 브라우저 자동화 도구, 사용자에게 묻지 않은 유료 생성 API·크레딧 사용)은 하지 않는다.
- **샌드박스 안에서 실행 중이면(Codex 기본 모드 등)**: `npm install` 은 네트워크가, `node render.mjs` 는 헤드리스 Chrome 실행이 필요하다. macOS 샌드박스에서는 Chrome 이 `bootstrap_check_in ... Permission denied (1100)` / `Failed to launch the browser process` 로 막힌다. 이 오류가 나면 우회하지 말고 바로 사용자에게 "샌드박스 밖 실행(권한 상승) 승인"을 요청해 같은 명령을 다시 실행한다. 승인을 받을 수 없으면 거기서 멈추고, 사용자가 일반 터미널에서 실행할 명령 한 줄(`cd <폴더> && node render.mjs mp4`)을 보고한다.
- `npm install` 이 `EPERM`(홈의 npm 캐시 권한)으로 실패하면 `npm install --no-audit --no-fund --cache .npm-cache` 로 이 폴더 안 캐시를 쓴다.
## 2. 파일 구조 (현재 폴더에 만든다)
```
package.json render.mjs hf.mjs fonts.json props.json src/index.tsx src/duchamp-frame.tsx
public/plates/texture.(png|jpg) ← 질감 판을 쓸 때만
out/video.mp4 out/cut<n>-a.png out/cut<n>-b.png out/hook0.png out/hook.png out/frames/c<n>-a|b|c.png out/check.json
```
## 3. 질감 판 (선택 — 기본은 `없음`)
이 영상은 판 없이 코드만으로 완성된다. 종이 결은 코드가 고정된 판으로 만든다 — 아주 작은 이(tooth)와 짧은 섬유가 영역마다 다른 세기로(고른 노이즈·세로 줄무늬·점은 넣지 않는다 — 지난 검수에서 스캔 자국으로 읽혔다), 그 위에 아주 낮은 주파수 얼룩. 움직이는 빛·흐린 얼룩 조명은 쓰지 않는다(렌더 티). 결은 종이에만 깔리고 잉크(글자·면)는 불투명하게 위에 찍힌다(가장자리만 인쇄처럼 살짝 거칠게). `질감 판` 은 그 얼룩 대신 실제 종이 사진의 명암만 빌리고 싶을 때만 쓴다. 코드는 판을 27×48 칸까지 줄였다가 흐리게 키워 **큰 명암만** 쓴다(판 속 글자·점·결은 남지 않는다).
- **없음**(기본): 아무것도 하지 않는다. props 에 `texture` 를 넣지 않는다.
- **자동**: 이미지 생성 도구가 있으면(예: Codex 내장 image_gen) 아래 프롬프트로 한 장 생성 → 생성된 원본 파일을 `cp` 로 `public/plates/texture.png` 에 복사(편집·리샘플 금지). 이미지 생성 도구가 없으면(Claude Code 등) 터미널에서 다른 생성 경로를 이 순서로 확인해 처음 되는 것으로 같은 프롬프트로 만든다 — ① `codex` CLI(`command -v codex`): `codex exec --skip-git-repo-check -s workspace-write -C . "내장 이미지 생성으로 이 그림을 한 장 만든다: <판 프롬프트>. 생성 원본을 편집 없이 <저장 경로> 로 복사"` ② `grok` CLI(`command -v grok`): `grok -p "Generate an image: <판 프롬프트>. Save it to <저장 경로>" --always-approve --max-turns 10` (해상도가 낮게 나올 수 있어 ①이 없을 때만) ③ 연결된 이미지 생성 MCP 도구(Higgsfield `generate_image` 등) — 사용자 크레딧이 드니 쓰기 전에 몇 장 만들지 말하고 승인받는다. 어느 경로로 판을 만들었는지 보고에 쓴다. 셋 다 없으면 `없음` 으로 넘어가고 보고에 그 사실을 쓴다.
- **내 사진: <경로>**: 사용자가 찍은 종이·벽 사진을 `public/plates/texture.<확장자>` 로 복사.
- 판 프롬프트: `Vertical 9:16 photograph of a plain sheet of uncoated off-white paper filling the whole frame, soft even daylight from the upper left, very gentle large-scale tonal variation only. NO text, letters, numbers, logos, stains, dots, specks, streaks, scan lines, folds or banding. Fully opaque, edge to edge.`
- 판을 쓰면 props 에 `"texture": "plates/texture.<확장자>"` 를 더한다.
## 4. 확인할 것 (렌더 전에)
판이 없으면 확인할 판도 없다. 항목마다 이름·전·후 세 칸이 다 있는지, 전과 후가 실제로 다른지 본다(같으면 그 항목은 빼자고 사용자에게 묻는다).
## 5. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"colors": {"paper": "<밝은 색>", "ink": "<어두운 색>", "accent": "<포인트색>"},
"cuts": [
{"sec": 2.6, "big": "<표지 날짜>", "line": "<표지 제목>"},
{"sec": 2.2, "item": {"label": "<항목 1 이름>", "before": "<전>", "after": "<후>"}},
{"sec": 2.2, "item": {"label": "<항목 2 이름>", "before": "<전>", "after": "<후>"}},
{"sec": 2.2, "item": {"label": "<항목 3 이름>", "before": "<전>", "after": "<후>"}},
{"sec": 2.6, "line": "<모아 보기 한 줄 — 없으면 null>"}
]
}
```
- 글자는 입력 그대로. 첫 컷은 표지, 마지막 컷은 모아 보기, 그 사이가 항목(1~3개)이다.
- 컷 수 3~5개, 합계 8~12초, 컷마다 1.8~4초. 항목 컷은 2.2초(이름·후 값과 전 값 절반을 읽는 시간 — 모자라면 `read-time`), 표지·모아 보기는 2.6초 이상.
- 크기·줄바꿈은 코드가 정한다: 날짜는 폭 912px·잉크 높이 400px 안에서 가장 크게, 항목 이름 최대 92px, 후 값 최대 124px(60px 아래로 줄어야 하면 실패).
## 6. 코드 (그대로 저장)
`package.json`
```json
{
"name": "duchamp-recipe",
"private": true,
"type": "module",
"dependencies": {
"hyperframes": "0.8.137",
"esbuild": "0.25.12",
"react": "19.1.0",
"react-dom": "19.1.0",
"ffmpeg-static": "5.3.0",
"@ffprobe-installer/ffprobe": "2.1.2",
"@fontsource/abril-fatface": "5.3.0",
"@fontsource-variable/noto-serif-kr": "5.3.0"
}
}
```
`render.mjs`
```js
// usage: node render.mjs stills | mp4
// stills → out/cut<n>-a.png(컷 시작 0.4초) · out/cut<n>-b.png(컷 60%, 대표) · out/hook0.png(0초)·hook.png(0.3초) + out/check.json
// mp4 → 검사 통과 후 out/video.mp4(무음, 1080×1920, 30fps) + 전환 겹침·그래픽↔글자 겹침 검사(모든 프레임) + 움직임 검사(컷마다 멈춘 구간) + out/frames/c<n>-a|b|c.png
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import {spawnSync} from 'node:child_process';
import {ff, meta, render} from './hf.mjs';
const mode = process.argv[2] ?? 'mp4';
const FPS = 30, W = 1080, H = 1920;
const props = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.rmSync('out', {recursive: true, force: true});
fs.mkdirSync('out/frames', {recursive: true});
const over = [], warn = [], hints = [];
const cuts = Array.isArray(props.cuts) ? props.cuts : [];
const tl = []; { let f = 0; for (const c of cuts) { const len = Math.round(c.sec * FPS); tl.push({start: f, len}); f += len; } }
const total = tl.length ? tl.at(-1).start + tl.at(-1).len : 0;
const rest = (t) => t.start + Math.round(t.len * 0.6);
// ① 파일: props 가 가리키는 사진·판이 있어야 한다
for (const k of ['photo', 'texture']) if (props[k] && !fs.existsSync(path.join('public', props[k]))) { over.push(k === 'photo' ? 'photo-missing' : 'texture-missing'); hints.push(`public/${props[k]} 파일이 없음`); }
// 제품 사진(투명 PNG): 에이전트 확인 confirm.json 이 있어야 한다(오린 가장자리·빠진 부분·남은 배경)
let confirm = null;
if (props.photo) {
if (!fs.existsSync('confirm.json')) { over.push('confirm-missing'); hints.push('오린 제품 사진을 직접 열어 보고 confirm.json 을 먼저 쓴다(레시피 4단계)'); }
else {
try { confirm = JSON.parse(fs.readFileSync('confirm.json', 'utf8')); } catch (e) { over.push('confirm-missing'); hints.push('confirm.json 을 읽을 수 없음: ' + e.message); }
if (confirm) {
if (confirm.whole !== true) { over.push('confirm-cutout'); hints.push('confirm.whole: 제품 전체(뚜껑·리드·손잡이 끝까지)가 빠짐없이 남았는지 직접 보고 true 로(잘렸으면 사진을 바꾸거나 다시 오린다)'); }
if (confirm.background !== false) { over.push('confirm-cutout'); hints.push('confirm.background: 탁자·벽·그림자 조각이 남아 있으면 true → 다시 오린다. 깨끗하면 false'); }
if (typeof confirm.text !== 'boolean') { over.push('confirm-text'); hints.push('confirm.text: 제품에 읽히는 글자·로고가 있는지 true/false 로'); }
else if (confirm.text) warn.push('photo-text: 제품에 글자·로고가 있다고 확인됨 — 사용자 브랜드 라벨이면 괜찮다. 남의 상표면 사진을 바꾼다(보고에 쓴다)');
if (typeof confirm.what !== 'string' || !confirm.what.trim()) { over.push('confirm-cutout'); hints.push('confirm.what: 오린 사진에 보이는 제품을 한 줄로'); }
}
}
}
// PNG 읽기(node 내장 zlib, 8비트 회색·RGB·RGBA) → 회색 Float32Array
const readPng = (file) => {
const b = fs.readFileSync(file); let p = 8, w = 0, h = 0, ct = 0; const idat = [];
while (p < b.length) { const len = b.readUInt32BE(p), type = b.toString('ascii', p + 4, p + 8), d = b.subarray(p + 8, p + 8 + len);
if (type === 'IHDR') { w = d.readUInt32BE(0); h = d.readUInt32BE(4); ct = d[9]; if (d[8] !== 8) throw new Error('PNG 8비트만: ' + file); }
if (type === 'IDAT') idat.push(d); if (type === 'IEND') break; p += 12 + len; }
const bpp = {0: 1, 2: 3, 4: 2, 6: 4}[ct]; const raw = zlib.inflateSync(Buffer.concat(idat)); const stride = w * bpp;
const px = Buffer.alloc(h * stride); let prev = Buffer.alloc(stride);
for (let y = 0; y < h; y++) { const f = raw[y * (stride + 1)], line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), out = px.subarray(y * stride, (y + 1) * stride);
for (let x = 0; x < stride; x++) { const a = x >= bpp ? out[x - bpp] : 0, up = prev[x], c = x >= bpp ? prev[x - bpp] : 0; let v = line[x];
if (f === 1) v += a; else if (f === 2) v += up; else if (f === 3) v += (a + up) >> 1; else if (f === 4) { const q = a + up - c, pa = Math.abs(q - a), pb = Math.abs(q - up), pc = Math.abs(q - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? up : c; }
out[x] = v & 255; }
prev = out; }
const g = new Float32Array(w * h);
for (let i = 0; i < w * h; i++) { const o = i * bpp; g[i] = bpp >= 3 ? (0.2126 * px[o] + 0.7152 * px[o + 1] + 0.0722 * px[o + 2]) / 255 : px[o] / 255; }
return {w, h, g};
};
// 작게 줄인 흑백(평균 풀링)
const pool = ({w, h, g}, ow, oh) => { const o = new Float32Array(ow * oh), n = new Float32Array(ow * oh);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const k = Math.floor(y * oh / h) * ow + Math.floor(x * ow / w); o[k] += g[y * w + x]; n[k]++; }
for (let k = 0; k < o.length; k++) o[k] /= n[k]; return o; };
const corr = (a, b) => { const n = a.length; let ma = 0, mb = 0; for (let i = 0; i < n; i++) { ma += a[i]; mb += b[i]; } ma /= n; mb /= n;
let sab = 0, sa = 0, sb = 0; for (let i = 0; i < n; i++) { const x = a[i] - ma, y = b[i] - mb; sab += x * y; sa += x * x; sb += y * y; } return sab / Math.sqrt(sa * sb + 1e-12); };
// ② 컷 화면 PNG + 브라우저 검사
const checks = {};
let hook = null;
const ovl = new Map(); // 프레임 → 겹친 글자 쌍
const gfx = new Map(); // 프레임 → 그래픽이 걸린 글자
const onBrowserLog = (l) => {
if (l.text.startsWith('DUCHAMP_OVL ')) { const o = JSON.parse(l.text.slice(12)); ovl.set(o.frame, o.pairs); return; }
if (l.text.startsWith('DUCHAMP_GFX ')) { const o = JSON.parse(l.text.slice(12)); gfx.set(o.frame, o.hits); return; }
if (l.text.startsWith('DUCHAMP_CHECK ')) { const c = JSON.parse(l.text.slice(14)); if (c.hook) hook = c; else checks[c.cut] = c; }
else if (l.type === 'error' || l.type === 'warning') console.error('[browser]', l.text);
};
const t0 = Date.now();
const sec = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
const comp = await meta(props);
// 확인 화면을 한 번에(프레임 목록 렌더): 0·0.3초 훅 + 컷마다 시작 0.4초·대표
const shots = [[0, 'out/hook0.png'], [9, 'out/hook.png'], ...tl.flatMap((t, i) => [[Math.min(t.start + 12, t.start + t.len - 1), `out/cut${i + 1}-a.png`], [rest(t), `out/cut${i + 1}-b.png`]])];
const r = await render({name: 'stills', props, m: comp, format: 'png', frames: shots.map((x) => x[0]), out: shots.map((x) => x[1])});
if (r.fail) { console.error('렌더 실패:', r.fail); process.exit(1); }
r.logs.forEach((text) => onBrowserLog({text, type: 'log'}));
for (let i = 0; i < tl.length; i++) console.log(`PNG out/cut${i + 1}-a.png · cut${i + 1}-b.png ${sec()}`);
const perCut = tl.map((_, i) => checks[i + 1] ?? null);
perCut.forEach((c, i) => { if (!c) { over.push('no-check'); hints.push(`컷 ${i + 1} 검사 기록 없음`); return; } over.push(...c.overflow, ...(c.pre?.overflow ?? [])); hints.push(...c.hint.map((h) => `컷 ${i + 1}: ${h}`), ...(c.pre?.hint ?? [])); });
if (!hook) { over.push('hook'); hints.push('0.3초 훅 검사 기록 없음'); } else { over.push(...hook.overflow); hints.push(...hook.hint); }
// 훅: 0초와 0.3초 화면이 달라야 한다(첫 프레임부터 완성된 정지 화면 금지 — 찍히듯·들어오듯 등장)
{ const a = pool(readPng('out/hook0.png'), 108, 192), b = pool(readPng('out/hook.png'), 108, 192); let d = 0; for (let q = 0; q < a.length; q++) d += Math.abs(a[q] - b[q]); d /= a.length;
if (hook) hook.entryDiff = +d.toFixed(4);
if (d < 0.02) { over.push('hook-static'); hints.push(`첫 0.3초 화면 차이 ${d.toFixed(4)} < 0.02 — 0프레임에 이미 완성된 정지 화면(코드 문제)`); } }
// ③ 컷끼리 너무 닮음(대표 화면 27×48 흑백 상관) — 컷마다 구도·면이 달라야 한다
const sm = tl.map((_, i) => pool(readPng(`out/cut${i + 1}-b.png`), 27, 48));
const similar = {pairs: [], max: 0, limit: 0.93};
for (let i = 0; i < sm.length; i++) for (let j = i + 1; j < sm.length; j++) { const r = +corr(sm[i], sm[j]).toFixed(3); similar.pairs.push([i + 1, j + 1, r]); similar.max = Math.max(similar.max, r); if (j === i + 1 && r > similar.limit) { over.push('cut-similar'); hints.push(`컷 ${i + 1}·${j + 1} 대표 화면이 거의 같다(상관 ${r}) — 코드 문제`); } }
const allOver = () => [...new Set(over)];
const check = {ok: false, overflow: [], warn, hint: '', video: perCut.find(Boolean)?.video ?? null, cuts: perCut.map((c, i) => c && {cut: i + 1, frame: c.frame, items: c.items, info: c.info}), hook, similar, confirm};
const save = () => { check.overflow = allOver(); check.ok = check.overflow.length === 0; check.hint = [...new Set(hints)].join(' / ') || null; fs.writeFileSync('out/check.json', JSON.stringify(check, null, 2)); };
save();
console.log('CHECK', check.ok ? 'ok' : 'FAIL ' + JSON.stringify(check.overflow), '→ out/check.json');
if (!check.ok) { console.error('검사 실패 → 여기서 멈춤(MP4 를 만들지 않음). out/check.json 의 overflow·hint 를 보고 원인을 고친 뒤 다시'); process.exit(2); }
if (mode === 'mp4') {
let lastP = -1;
const v = await render({name: 'video', props, m: comp, format: 'mp4', crf: 17, out: 'out/video.mp4',
onProgress: (q0) => { const q = Math.floor(q0 * 10); if (q !== lastP) { lastP = q; console.log(`MP4 ${q * 10}% ${sec()}`); } }});
if (v.fail) { console.error('렌더 실패:', v.fail); process.exit(1); }
v.logs.forEach((text) => onBrowserLog({text, type: 'log'}));
// ④ 움직임 검사: 영상 전체를 108×192 흑백 프레임으로 풀어, 0.5초 떨어진 두 프레임의 평균 밝기 차 d 를 잰다.
// d < 0.010 이 이어지는 구간 = 멈춘(듯한) 화면. 컷 안에서 0.8초 넘게 이어지면 실패(motion-still). 컷 평균 d 도 기록한다
// 문턱 근거(20차): 19차 검수에서 "거의 정지"로 읽힌 구간은 d 0.004~0.014(느린 줌·진행선만), "움직인다"로 읽힌 구간은 0.017 이상
const md = 'out/_motion'; fs.mkdirSync(md, {recursive: true});
ff(['-v', 'error', '-y', '-i', path.resolve('out/video.mp4'), '-vf', 'scale=108:192', '-pix_fmt', 'gray', path.join(md, '%05d.png')]);
const files = fs.readdirSync(md).filter((f) => f.endsWith('.png')).sort();
const fr = files.map((f) => readPng(path.join(md, f)).g);
const EPS = 0.010, GAP = 15;
const motion = tl.map((t, i) => {
let run = 0, longest = 0, sum = 0, n = 0, minD = 1;
for (let k = t.start + GAP; k < Math.min(t.start + t.len, fr.length); k++) {
const a = fr[k], b = fr[k - GAP]; let d = 0; for (let q = 0; q < a.length; q++) d += Math.abs(a[q] - b[q]); d /= a.length;
sum += d; n++; minD = Math.min(minD, d);
if (d < EPS) { run++; longest = Math.max(longest, run); } else run = 0;
}
const stillSec = longest ? (longest + GAP) / FPS : 0;
return {cut: i + 1, meanD: +(sum / Math.max(1, n)).toFixed(4), minD: +minD.toFixed(4), longestStillSec: +stillSec.toFixed(2)};
});
motion.forEach((m) => { if (m.longestStillSec > 0.8) { over.push('motion-still'); hints.push(`컷 ${m.cut}: ${m.longestStillSec}초 동안 화면이 거의 멈춤 — 코드 문제(컷 길이를 늘렸다면 줄인다)`); } });
fs.rmSync(md, {recursive: true, force: true});
// 확인용 프레임(영상에서 직접): 컷마다 시작 0.13초·대표(60%)·끝 직전 → out/frames/c<n>-a|b|c.png (가로 540)
for (let i = 0; i < tl.length; i++) {
const t = tl[i], at = {a: (t.start + 4) / FPS, b: rest(t) / FPS, c: (t.start + t.len - 4) / FPS};
for (const [k, s] of Object.entries(at)) ff(['-v', 'error', '-y', '-ss', s.toFixed(3), '-i', path.resolve('out/video.mp4'), '-frames:v', '1', '-vf', 'scale=540:-2', path.resolve(`out/frames/c${i + 1}-${k}.png`)]);
}
const info = ff(['-i', path.resolve('out/video.mp4')]).stderr ?? '';
const m = /Duration: (\d+):(\d+):([\d.]+)/.exec(info);
check.motion = motion;
// 전환 겹침: 컷이 다른 글자 둘이 같은 자리에 동시에 보인 프레임 수 — 0 이어야 한다(나가는 글자가 다 빠진 뒤 들어온다)
check.overlap = {frames: ovl.size, sample: [...ovl.entries()].slice(0, 6).map(([f, p]) => ({frame: f, pairs: p}))};
// 그래픽 ↔ 글자 겹침(모든 프레임): 해·햇살·원·선 같은 그래픽이 글자 잉크(+12px)에 걸린 프레임 수 — 0 이어야 한다
check.gfx = {frames: gfx.size, sample: [...gfx.entries()].slice(0, 6).map(([f, h]) => ({frame: f, hits: h}))};
if (gfx.size) { over.push('gfx-collide'); hints.push(`그래픽이 글자에 걸린 프레임 ${gfx.size}개(예: 프레임 ${[...gfx.keys()].slice(0, 4).join(', ')}: ${[...gfx.values()][0][0]})`); }
if (ovl.size) { over.push('text-overlap'); hints.push(`전환 중 글자 겹침 ${ovl.size}프레임(예: 프레임 ${[...ovl.keys()].slice(0, 4).join(', ')}) — 코드 문제`); }
check.mp4 = {file: 'out/video.mp4', seconds: m ? +m[1] * 3600 + +m[2] * 60 + +m[3] : null, expect: +(total / FPS).toFixed(2), size: /(\d{3,4})x(\d{3,4})/.exec(info)?.[0] ?? null, fps: FPS, audio: /Audio:/.test(info)};
if (check.mp4.audio) { over.push('audio'); hints.push('MP4 에 소리 트랙이 있음 — 무음이어야 한다'); }
save();
console.log(`MP4 out/video.mp4 ${check.mp4.seconds}s · ${check.mp4.size} · 소리 ${check.mp4.audio ? '있음(!)' : '없음'} · 움직임 ${motion.map((x) => x.longestStillSec + 's').join('/')} ${sec()}`);
console.log('CHECK', check.ok ? 'ok' : 'FAIL ' + JSON.stringify(check.overflow));
process.exit(check.ok ? 0 : 2);
}
process.exit(0);
```
`hf.mjs`
```js
// HyperFrames 렌더 도우미 — render.mjs 가 쓴다. src/index.tsx(+ src/duchamp-frame.tsx)를 esbuild 로 묶어 .render/<작업>/ 에 HyperFrames 프로젝트를 만들고 hyperframes CLI 로 PNG·MP4 를 뽑는다
// 크기·fps·길이는 같은 코드를 Node 에서 한 번 읽어 정한다(meta). 정지 이미지 = 그릴 프레임 목록만큼의 짧은 렌더(png-sequence).
// CLI 는 텔레메트리·업데이트 확인을 끄고, Chrome 은 이 폴더 안(.render/home)에 받는다.
import {build} from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import {spawn, spawnSync} from 'node:child_process';
import {pathToFileURL} from 'node:url';
import {createHash} from 'node:crypto';
// ffmpeg·ffprobe: npm 패키지(ffmpeg-static·@ffprobe-installer) → 없거나 안 돌면(설치 스크립트가 막힌 환경 등) 시스템 ffmpeg
const runs = (bin) => Boolean(bin) && spawnSync(bin, ['-version'], {stdio: 'ignore'}).status === 0;
const tool = async (pkg, pick, bin) => { try { const p = pick(await import(pkg)); if (runs(p)) return p; } catch { /* 시스템 것으로 */ } return runs(bin) ? bin : null; };
export const FFMPEG = await tool('ffmpeg-static', (m) => m.default, 'ffmpeg');
const FFPROBE = await tool('@ffprobe-installer/ffprobe', (m) => (m.default ?? m).path, 'ffprobe');
if (!FFMPEG || !FFPROBE) {
console.error(`${FFMPEG ? 'ffprobe 를' : 'ffmpeg 을'} 찾지 못함 → \`npm rebuild ffmpeg-static\` 으로 다시 받거나 시스템에 ffmpeg 를 설치한 뒤 다시(macOS: brew install ffmpeg · Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg)`);
process.exit(1);
}
export const ff = (args) => spawnSync(FFMPEG, ['-hide_banner', ...args], {encoding: 'utf8', maxBuffer: 1 << 26});
const R = path.resolve('.render');
const VIDEO = /\.(mp4|mov|m4v|webm)$/i;
let bundled = null;
const bundleOnce = () => (bundled ??= build({
entryPoints: [path.resolve('src/index.tsx')], bundle: true, format: 'iife', platform: 'browser', target: 'chrome120', jsx: 'automatic',
define: {'process.env.NODE_ENV': '"production"'}, minify: true, outfile: path.join(R, 'bundle.js'), logLevel: 'error',
}));
// 크기·fps·길이(프레임) — calculateMetadata 가 있으면 props 로 계산
let metaN = 0;
export async function meta(props) {
const out = path.join(R, 'meta.mjs');
if (!metaN) await build({entryPoints: [path.resolve('src/index.tsx')], bundle: true, format: 'esm', platform: 'node', jsx: 'automatic', packages: 'external', outfile: out, logLevel: 'error'});
globalThis.__DUCHAMP_INPUT = props;
await import(pathToFileURL(out).href + '?n=' + ++metaN);
return await globalThis.__DUCHAMP_META;
}
// props 안의 영상 파일(public/ 기준) → fps 에 맞춘 프레임 JPEG(.render/vframes/<n>/000001.jpg …)
// · 쓰는 구간만: 그 영상을 가리키는 항목에 sec(+clipStart)가 있으면 [clipStart − 1초, clipStart + sec + 1초] 만 뽑는다(없으면 전체)
// · 크기: 화면(W×H)의 1.25배를 덮는 크기까지만 줄인다(4K 를 그대로 쌓지 않는다) · 회전 메타데이터는 ffmpeg 가 바로 세운다
// · HDR(PQ·HLG, BT.2020): zscale + tonemap(mobius)으로 SDR BT.709 로 바꾼다. zscale 이 없는 ffmpeg 면 경고만 남기고 그대로(색이 바랠 수 있음)
const videoCache = new Map();
let zscale = null;
const hasZscale = () => (zscale ??= /\bzscale\b/.test(ff(['-filters']).stdout ?? ''));
const probe = (file) => {
const r = spawnSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0', '-show_streams', '-of', 'json', file], {encoding: 'utf8', maxBuffer: 1 << 24});
try {
const st = JSON.parse(r.stdout).streams[0];
const deg = Number(st.side_data_list?.find((d) => d.rotation !== undefined)?.rotation ?? st.tags?.rotate ?? 0), rot = Math.abs(deg) % 180 === 90;
return {w: rot ? st.height : st.width, h: rot ? st.width : st.height, transfer: st.color_transfer ?? ''};
} catch { return {w: 0, h: 0, transfer: ''}; }
};
// HDR(PQ·HLG) 영상 → SDR BT.709 필터 목록(SDR 이면 빈 목록). 기준 흰색(PQ 203nit · HLG 75%)을 SDR 흰색에 맞추고, 그 위 하이라이트만 mobius 로 눌러 담는다(BT.2408 방식)
// reel-render 의 판 포스터도 같은 필터를 쓴다
export function sdrFilter(file, info = probe(file)) {
if (!/smpte2084|arib-std-b67/.test(info.transfer)) return [];
if (hasZscale()) return [`zscale=t=linear:npl=${/2084/.test(info.transfer) ? 203 : 100}`, 'format=gbrpf32le', 'zscale=p=bt709', 'tonemap=tonemap=mobius:param=0.8:desat=0', 'zscale=t=bt709:m=bt709:r=tv', 'format=yuv420p'];
console.error(`[경고] ${file} 는 HDR(${info.transfer})인데 이 ffmpeg 에 zscale 이 없어 SDR 로 바꾸지 못함 → 색이 바랠 수 있다. ffmpeg-static 을 쓰거나 SDR 로 내보낸 클립을 쓴다`);
return [];
}
function videoFrames(props, m) {
const fps = m.fps, found = new Map();
const walk = (v) => {
if (!v || typeof v !== 'object') return;
for (const x of Object.values(v)) {
if (typeof x === 'string' && VIDEO.test(x) && fs.existsSync(path.join('public', x))) {
const r = typeof v.sec === 'number' ? [Math.max(0, (v.clipStart ?? 0) - 1), (v.clipStart ?? 0) + v.sec + 1] : null;
const cur = found.get(x);
found.set(x, cur === undefined ? r : cur && r ? [Math.min(cur[0], r[0]), Math.max(cur[1], r[1])] : null);
} else walk(x);
}
};
walk(props);
const videos = {};
for (const [src, range] of found) {
const key = `${src}@${fps}@${range}`;
if (!videoCache.has(key)) {
const dir = `vframes/${videoCache.size + 1}`, abs = path.join(R, dir), file = path.join('public', src), info = probe(file);
fs.rmSync(abs, {recursive: true, force: true}); fs.mkdirSync(abs, {recursive: true});
const first = range ? Math.floor(range[0] * fps) : 0;
const vf = sdrFilter(file, info);
const W2 = Math.round(m.width * 1.25), H2 = Math.round(m.height * 1.25);
if (info.w > W2 && info.h > H2) vf.push(info.w * H2 > info.h * W2 ? `scale=-2:${H2}:flags=lanczos` : `scale=${W2}:-2:flags=lanczos`);
vf.push(`fps=${fps}`);
const r = ff(['-v', 'error', '-y', ...(range ? ['-ss', (first / fps).toFixed(4), '-t', (range[1] - first / fps).toFixed(4)] : []), '-i', file, '-an', '-vf', vf.join(','), '-q:v', '2', path.join(abs, '%06d.jpg')]);
const count = fs.readdirSync(abs).filter((f) => f.endsWith('.jpg')).length;
if (!count) throw new Error(`영상 프레임을 못 뽑음: public/${src} ${(r.stderr ?? '').slice(-300)}`);
videoCache.set(key, {dir, first, count});
}
videos[src] = videoCache.get(key);
}
return videos;
}
// 폰트(fonts.json): 설치된 폰트 패키지의 css 를 읽어 @font-face 목록을 만든다 → 렌더 페이지(duchamp-frame)가 Google Fonts·jsDelivr 주소 대신 이 로컬 파일을 쓴다
// .render/fonts/<n> → node_modules/<패키지> (링크). 목록에 있는데 설치 안 된 패키지는 건너뛴다(그 폰트를 부르면 렌더가 '폰트 패키지 없음'으로 멈춤)
let fontIndex = null;
// 자가 호스팅 폰트(fonts.json 의 download): .render/font-cache/<이름>/ 에 받아 둔다(sha256 확인). 받는 주소는 DUCHAMP_FONT_BASE 로 바꿀 수 있다(시험용)
// 컴포지션(src/index.tsx)이 실제로 부르는 폰트만(Google Fonts 주소의 family=이름)
async function downloadFonts(manifest) {
const code = fs.existsSync('src/index.tsx') ? fs.readFileSync('src/index.tsx', 'utf8') : '';
const sha = (file) => fs.existsSync(file) && createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const get = async (name, base, file, out, want) => {
if (sha(out) === want) return;
const url = (process.env.DUCHAMP_FONT_BASE ? process.env.DUCHAMP_FONT_BASE.replace(/\/?$/, '/') + path.basename(base) + '/' : base) + file;
const r = await fetch(url).catch((e) => ({ok: false, status: e.message}));
if (!r.ok) throw new Error(`폰트 파일을 못 받음: ${name} ${url} (${r.status}) — 인터넷 연결을 확인하고 \`node hf.mjs fonts\` 를 다시`);
fs.writeFileSync(out, Buffer.from(await r.arrayBuffer()));
if (sha(out) !== want) { fs.rmSync(out, {force: true}); throw new Error(`폰트 파일 확인값이 다름: ${name} ${url} — 받은 내용이 기대와 다르다(fonts.json 을 레시피와 글자 하나까지 같게 저장했는지, 주소가 배포됐는지 확인)`); }
};
for (const [name, f] of Object.entries(manifest)) {
if (!f.download || !(code.includes('family=' + name.replace(/ /g, '+')) || (f.url && code.includes(f.url)))) continue;
const dir = path.join(R, 'font-cache', name.replace(/[^\w-]+/g, '-')); fs.mkdirSync(dir, {recursive: true});
// 조각 목록(faces.json — 파일·unicode-range·sha256)은 fonts.json 의 sha256 으로 확인한 뒤 그 안의 woff2 를 받는다
await get(name, f.download.base, f.download.faces, path.join(dir, 'faces.json'), f.download.sha256);
for (const x of JSON.parse(fs.readFileSync(path.join(dir, 'faces.json'), 'utf8'))) await get(name, f.download.base, x.file, path.join(dir, x.file), x.sha256);
}
}
export async function prepareFonts() {
const manifest = fs.existsSync('fonts.json') ? JSON.parse(fs.readFileSync('fonts.json', 'utf8')) : {};
await downloadFonts(manifest);
return fonts();
}
function fonts() {
if (fontIndex) return fontIndex;
const manifest = fs.existsSync('fonts.json') ? JSON.parse(fs.readFileSync('fonts.json', 'utf8')) : {};
const root = path.join(R, 'fonts'); fs.rmSync(root, {recursive: true, force: true}); fs.mkdirSync(root, {recursive: true});
fontIndex = {};
let n = 0;
for (const [name, f] of Object.entries(manifest)) {
if (name === '_') continue;
if (f.download) {
if (!fs.existsSync(path.join(R, 'font-cache', name.replace(/[^\w-]+/g, '-'), 'faces.json'))) continue;
const dir = `fonts/${++n}`; fs.symlinkSync(path.join(R, 'font-cache', name.replace(/[^\w-]+/g, '-')), path.join(R, dir));
const [w0, w1] = [f.download.weight].flat();
fontIndex[name] = {faces: JSON.parse(fs.readFileSync(path.join(R, 'font-cache', name.replace(/[^\w-]+/g, '-'), 'faces.json'), 'utf8')).map((x) => ({w: [w0, w1 ?? w0], variable: w1 !== undefined, range: x.range, url: `${dir}/${x.file}`})), url: f.url ?? null};
continue;
}
if (!f.package) continue;
const pkg = path.resolve('node_modules', f.package);
if (!fs.existsSync(path.join(pkg, 'package.json'))) continue;
const dir = `fonts/${++n}`; fs.symlinkSync(pkg, path.join(R, dir));
const faces = [];
for (const css of f.css) {
const text = fs.readFileSync(path.join(pkg, css), 'utf8');
for (const [, body] of text.matchAll(/@font-face\s*{([^}]*)}/g)) {
const get = (k) => new RegExp(`${k}\\s*:\\s*([^;]+);`).exec(body)?.[1].trim();
const fam = get('font-family')?.replace(/['"]/g, ''), src = /url\(([^)]+\.woff2)\)/.exec(body)?.[1].replace(/['"]/g, '');
if (!src || (fam && fam !== (f.family ?? name)) || (get('font-style') ?? 'normal') !== 'normal') continue;
const [w0, w1] = (get('font-weight') ?? '400').split(/\s+/).map(Number);
faces.push({w: [w0, w1 ?? w0], variable: /woff2-variations/.test(body) || w1 !== undefined, range: get('unicode-range') ?? null, url: path.posix.join(dir, path.posix.dirname(css), src)});
}
}
fontIndex[name] = {faces, url: f.url ?? null};
}
return fontIndex;
}
// 오프라인 시험(DUCHAMP_OFFLINE_TEST=1): Chrome 이 localhost 말고는 이름을 못 찾게 띄운다(Google Fonts·jsDelivr 포함 모든 외부 요청 실패)
function offlineBrowser() {
const home = path.join(R, 'home', '.cache', 'hyperframes', 'chrome');
const find = (d) => { for (const e of fs.existsSync(d) ? fs.readdirSync(d, {withFileTypes: true}) : []) { const p = path.join(d, e.name); if (e.isDirectory()) { const r = find(p); if (r) return r; } else if (e.name === 'chrome-headless-shell') return p; } return null; };
const chrome = find(home);
if (!chrome) throw new Error('오프라인 시험: Chrome 이 아직 없음 → 한 번 보통으로 렌더해 받은 뒤 다시');
const w = path.join(R, 'chrome-offline.sh');
fs.writeFileSync(w, `#!/bin/sh\nexec "${chrome}" --host-resolver-rules="MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1" "$@"\n`, {mode: 0o755});
return w;
}
const linkIn = (dir, name, target) => { const p = path.join(dir, name); fs.rmSync(p, {recursive: true, force: true}); fs.symlinkSync(target, p); };
// 렌더. 시간 초과·폰트 로드 실패(네트워크 흔들림)면 두 번까지 다시 시도한다
export async function render(o) {
for (let k = 0; ; k++) {
const r = await renderOnce(o);
if (!r.fail || k === 2 || !/시간 초과|폰트|Fonts|CSS 로드 실패/.test(r.fail)) return r;
console.error(`[다시 시도 ${k + 1}/2] ${r.fail}`);
}
}
// 렌더 한 번. format 'png' = frames 목록의 프레임을 out(파일 경로 배열, 같은 순서)으로 / 'mp4' = 처음부터 끝까지 out 파일로
// 돌려주는 값: {logs: 브라우저 console 줄들, fail: DUCHAMP_FAIL 메시지 또는 null}
async function renderOnce({name, props, m, format, frames = null, out, crf = 17, workers, onProgress}) {
await bundleOnce();
const dir = path.join(R, name);
fs.rmSync(dir, {recursive: true, force: true}); fs.mkdirSync(dir, {recursive: true});
fs.copyFileSync(path.join(R, 'bundle.js'), path.join(dir, 'bundle.js'));
linkIn(dir, 'public', path.resolve('public'));
fs.mkdirSync(path.join(R, 'vframes'), {recursive: true});
linkIn(dir, 'vframes', path.join(R, 'vframes'));
const fontList = await prepareFonts();
linkIn(dir, 'fonts', path.join(R, 'fonts'));
const job = {props, frames, videos: videoFrames(props, m), fonts: fontList};
const n = frames ? frames.length : m.durationInFrames;
const json = JSON.stringify(job).replace(/</g, '\\u003c');
fs.writeFileSync(path.join(dir, 'index.html'), `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>window.__hf = window.__hf || {}; window.__hf.buildReady = Object.assign(window.__hf.buildReady || {}, {duchamp: new Promise((r) => { window.__duchampReady = r; })}); window.__DUCHAMP = ${json};</script>
<style>* { box-sizing: border-box; } html, body { margin: 0; width: ${m.width}px; height: ${m.height}px; overflow: hidden; background: transparent; }</style>
</head>
<body>
<div id="root" data-composition-id="main" data-no-timeline data-start="0" data-duration="${n / m.fps}" data-width="${m.width}" data-height="${m.height}" data-fps="${m.fps}" style="position:relative;width:${m.width}px;height:${m.height}px;overflow:hidden"></div>
<script src="bundle.js"></script>
</body>
</html>
`);
const target = format === 'png' ? path.join(dir, 'frames') : path.resolve(out);
if (format !== 'png') fs.mkdirSync(path.dirname(target), {recursive: true});
const args = [path.resolve('node_modules/hyperframes/bin/hyperframes.mjs'), 'render', dir, '-o', target, '-f', String(m.fps), '-w', String(workers ?? (format === 'png' ? 1 : 'auto')),
...(format === 'png' ? ['--format', 'png-sequence'] : ['--format', 'mp4', '--crf', String(crf)])];
fs.mkdirSync(path.join(R, 'home'), {recursive: true});
const env = {...process.env, HOME: path.join(R, 'home'), HYPERFRAMES_NO_TELEMETRY: '1', DO_NOT_TRACK: '1', HYPERFRAMES_NO_UPDATE_CHECK: '1', HYPERFRAMES_NO_AUTO_INSTALL: '1', HYPERFRAMES_SKIP_SKILLS: '1',
HYPERFRAMES_FFMPEG_PATH: FFMPEG, HYPERFRAMES_FFPROBE_PATH: FFPROBE, ...(process.env.DUCHAMP_OFFLINE_TEST === '1' ? {HYPERFRAMES_BROWSER_PATH: offlineBrowser()} : {})};
const logs = [], tail = [];
let fail = null;
const code = await new Promise((res) => {
const p = spawn(process.execPath, args, {env, stdio: ['ignore', 'pipe', 'pipe']});
let buf = '';
const onData = (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).replace(/\x1b\[[0-9;]*m/g, ''); buf = buf.slice(i + 1);
tail.push(line); if (tail.length > 60) tail.shift();
const b = /\[Browser(?::(ERROR|WARN))?\] (.*)$/.exec(line);
if (b) {
logs.push(b[2]);
if (b[2].startsWith('DUCHAMP_FAIL ')) fail ??= b[2].slice(13);
else if (b[1] && !b[2].startsWith('DUCHAMP_')) console.error('[browser]', b[2]);
}
const c = /Capturing frame (\d+)\/(\d+)/.exec(line);
if (c) onProgress?.(+c[1] / +c[2]);
}
};
p.stdout.on('data', onData); p.stderr.on('data', onData);
p.on('close', res);
});
if (fail) return {logs, fail};
if (code !== 0) throw new Error(`hyperframes render 실패(종료 코드 ${code})\n${tail.join('\n')}`);
if (format === 'png') {
const got = fs.readdirSync(target).filter((f) => f.endsWith('.png')).sort();
if (got.length !== n) throw new Error(`PNG ${got.length}장(기대 ${n}장)\n${tail.join('\n')}`);
got.forEach((f, k) => { fs.mkdirSync(path.dirname(path.resolve(out[k])), {recursive: true}); fs.renameSync(path.join(target, f), path.resolve(out[k])); });
}
return {logs, fail: null};
}
// 브라우저 줄들 중 TAG 로 시작하는 JSON
export const pick = (logs, tag) => logs.filter((l) => l.startsWith(tag + ' ')).map((l) => JSON.parse(l.slice(tag.length + 1)));
// node hf.mjs fonts — 자가 호스팅 폰트를 미리 받는다(package.json 의 postinstall 이 부른다 → npm install 뒤에는 네트워크 없이 렌더)
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve('hf.mjs') && process.argv[2] === 'fonts') {
// npm install 을 깨뜨리지 않는다: 못 받으면 경고만 — 렌더할 때 다시 받고, 그래도 안 되면 그때 멈춘다
try { const list = await prepareFonts(); console.log('폰트', Object.entries(list).map(([k, v]) => `${k}(${v.faces.length})`).join(' · ')); }
catch (e) { console.error('[경고]', e.message); }
}
```
`fonts.json`
```json
{
"_": "레시피 폰트 목록 — 컴포지션이 부르는 Google Fonts·jsDelivr CSS 주소의 family 이름 → 이 폴더에 npm 으로 받는 폰트 패키지(package) 또는 duchamp.app 에 자가 호스팅한 woff2(download — 조각 목록 faces.json(sha256 고정)과 woff2 를 npm install 때 .render/font-cache 에 받아 둔다). hf.mjs 가 여기 적힌 css 를 읽어 렌더 페이지 안에서 로컬 파일로 바꿔 준다(네트워크 없음). 폰트를 더하려면 한 줄 추가 + 레시피의 package.json 다시 만들기(pnpm recipes). 필드: family(표시 이름) · category(고딕/명조/손글씨/디스플레이/모노/라틴) · weights(정적은 굵기 목록, 가변은 [최소, 최대]) · variable · hangul(완성형 한글 글자 수: 11172 = 전부, 2350 = KS X 1001 상용, 0 = 라틴 전용) · license · source.",
"Abril Fatface": {"family": "Abril Fatface", "category": "라틴", "weights": [400], "variable": false, "hangul": 0, "package": "@fontsource/abril-fatface", "version": "5.3.0", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Anton": {"family": "Anton", "category": "라틴", "weights": [400], "variable": false, "hangul": 0, "package": "@fontsource/anton", "version": "5.3.0", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Black Han Sans": {"family": "Black Han Sans", "category": "디스플레이", "weights": [400], "variable": false, "hangul": 2581, "package": "@fontsource/black-han-sans", "version": "5.3.0", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Bodoni Moda": {"family": "Bodoni Moda Variable", "category": "라틴", "weights": [400, 900], "variable": true, "hangul": 0, "package": "@fontsource-variable/bodoni-moda", "version": "5.3.0", "css": ["opsz.css"], "axes": "opsz 6–96 · wght 400–900", "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Gothic A1": {"family": "Gothic A1", "category": "고딕", "weights": [400, 500, 600, 700, 800], "variable": false, "hangul": 11172, "package": "@fontsource/gothic-a1", "version": "5.3.0", "css": ["400.css", "500.css", "600.css", "700.css", "800.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Hahmlet": {"family": "Hahmlet Variable", "category": "명조", "weights": [100, 900], "variable": true, "hangul": 2788, "package": "@fontsource-variable/hahmlet", "version": "5.3.0", "css": ["wght.css"], "axes": "wght 100–900", "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"IBM Plex Mono": {"family": "IBM Plex Mono", "category": "모노", "weights": [400, 700], "variable": false, "hangul": 0, "package": "@fontsource/ibm-plex-mono", "version": "5.3.0", "css": ["400.css", "700.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"IBM Plex Sans KR": {"family": "IBM Plex Sans KR", "category": "고딕", "weights": [500, 700], "variable": false, "hangul": 11172, "package": "@fontsource/ibm-plex-sans-kr", "version": "5.3.0", "css": ["500.css", "700.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Jua": {"family": "Jua", "category": "디스플레이", "weights": [400], "variable": false, "hangul": 2367, "package": "@fontsource/jua", "version": "5.3.0", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Nanum Gothic Coding": {"family": "Nanum Gothic Coding", "category": "모노", "weights": [400], "variable": false, "hangul": 11172, "package": "@fontsource/nanum-gothic-coding", "version": "5.3.0", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Noto Sans KR": {"family": "Noto Sans KR Variable", "category": "고딕", "weights": [100, 900], "variable": true, "hangul": 11172, "package": "@fontsource-variable/noto-sans-kr", "version": "5.3.0", "css": ["wght.css"], "axes": "wght 100–900", "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Noto Serif KR": {"family": "Noto Serif KR Variable", "category": "명조", "weights": [200, 900], "variable": true, "hangul": 11172, "package": "@fontsource-variable/noto-serif-kr", "version": "5.3.0", "css": ["wght.css"], "axes": "wght 200–900", "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Song Myung": {"family": "Song Myung", "category": "명조", "weights": [400], "variable": false, "hangul": 2350, "package": "@fontsource/song-myung", "version": "5.3.1", "css": ["400.css"], "license": "OFL-1.1", "source": "Google Fonts → Fontsource"},
"Source Serif 4": {"family": "Source Serif 4", "category": "라틴", "weights": [900], "variable": false, "hangul": 0, "download": {"base": "https://duchamp.app/recipe-fonts/source-serif-4-opsz36-wght900/", "weight": 900, "faces": "faces.json", "sha256": "e1b6979fb1b766489fcdc4bd890387ec2f84c9959541cb688cf029716f5e92cc"}, "license": "OFL-1.1", "source": "Google Fonts 정적 인스턴스(opsz 36·wght 900) 그대로 — duchamp.app 자가 호스팅(npm install 때 받아 .render/font-cache 에 둔다)", "why": "Fontsource 가변 파일을 opsz 36·wght 900 에 고정하면 글자 폭이 0.3px 달라 숫자 간격 검사 값이 1px 바뀐다"},
"Pretendard Variable": {"family": "Pretendard Variable", "category": "고딕", "weights": [45, 920], "variable": true, "hangul": 11172, "download": {"base": "https://duchamp.app/recipe-fonts/pretendard-1.3.9-variable/", "weight": [45, 920], "faces": "faces.json", "sha256": "de666a0873136f7f62b67b0bf39cc5a78720e31a2a2c83c3fc72dbbd677c1769"}, "axes": "wght 45–920", "license": "OFL-1.1", "source": "orioncactus/pretendard v1.3.9 dynamic-subset 92조각 그대로(한글 11,172자 전부) — duchamp.app 자가 호스팅", "url": "https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css", "why": "npm pretendard 는 97MB(설치 증가 기준 50MB 초과) — 쓰는 가변 woff2 조각 3.0MB 만 받는다"}
}
```
`src/index.tsx`
```tsx
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {AbsoluteFill, Composition, Easing, Img, cancelRender, continueRender, delayRender, interpolate, registerRoot, staticFile, useCurrentFrame} from './duchamp-frame';
// ── Duchamp 코드 레시피 · 세로 모션그래픽 영상 공용 (9:16 1080×1920 · 30fps · 무음) ──
// 글자·면·선·움직임은 전부 이 코드가 실제 폰트로 그린다. 이미지 모델·AI 영상 생성은 쓰지 않는다(제품 사진·질감 판만 선택).
const W = 1080, H = 1920, FPS = 30;
// 안전 영역: 위 12%(계정 이름·닫기 버튼), 아래 35%(설명·음악·버튼), 좌우 64px — 꼭 읽혀야 하는 글자의 잉크는 이 안에만
const SAFE_TOP = Math.round(H * 0.12), SAFE_BOTTOM = Math.round(H * 0.65), SIDE = 64;
// 폰트: CSS 를 브라우저 안에서 불러오고 실제 글자로 확인한다. 못 불러오면 렌더 실패(대체 글꼴로 조용히 그리지 않는다)
const loadFonts = async (css: string[], specs: [string, string][]) => {
for (const href of css) {
await new Promise<void>((res, rej) => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = href;
l.onload = () => res(); l.onerror = () => rej(new Error('폰트 CSS 로드 실패: ' + href));
document.head.appendChild(l);
});
}
for (const [s, t] of specs) {
if (!t) continue;
await document.fonts.load(s, t);
if (!document.fonts.check(s, t)) throw new Error('폰트 없음: ' + s + ' / ' + t);
}
await document.fonts.ready;
};
const loadImage = (src: string) => new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i); i.onerror = () => rej(new Error('이미지 로드 실패: ' + src)); i.src = src;
});
const settle = () => new Promise<void>((r) => setTimeout(() => requestAnimationFrame(() => r()), 30));
const report = (r: Record<string, unknown>) => console.log('DUCHAMP_CHECK ' + JSON.stringify(r));
// ── 움직임 ──
const OUT = Easing.bezier(0.16, 1, 0.3, 1); // 빠르게 나와 부드럽게 멈춤(expo-out)
const INOUT = Easing.bezier(0.83, 0, 0.17, 1);
const SNAP = Easing.bezier(0.5, 0, 0.1, 1); // 끊어 치듯 들어감
const BACK = Easing.bezier(0.34, 1.45, 0.64, 1); // 살짝 넘쳤다가 제자리
const IN = Easing.bezier(0.6, 0, 0.9, 0.4);
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
const ramp = (f: number, a: number, b: number, ease = OUT) => interpolate(f, [a, b], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ease});
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// 컷 시간표
type CutBase = {sec: number};
const timeline = (cuts: CutBase[]) => { let f = 0; return cuts.map((c) => { const len = Math.round(c.sec * FPS); const t = {start: f, len}; f += len; return t; }); };
const totalFrames = (cuts: CutBase[]) => cuts.reduce((s, c) => s + Math.round(c.sec * FPS), 0);
const restFrame = (t: {start: number; len: number}) => t.start + Math.round(t.len * 0.6); // 컷 대표 화면(검사·확인) = 컷 60%
// ── 색 ──
const hexRgb = (h: string) => { const n = parseInt(h.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const rgbHex = (c: number[]) => '#' + c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
const mix = (a: string, b: string, t: number) => { const x = hexRgb(a), y = hexRgb(b); return rgbHex(x.map((v, i) => v + (y[i] - v) * t)); };
const relLum = (c: number[]) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
const contrast = (a: number[] | string, b: number[] | string) => {
const la = relLum(typeof a === 'string' ? hexRgb(a) : a), lb = relLum(typeof b === 'string' ? hexRgb(b) : b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
};
const hsl = (c: number[]) => {
const [r, g, b] = c.map((v) => v / 255), mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, d = mx - mn;
if (d < 1e-6) return {h: 0, s: 0, l};
const s = d / (1 - Math.abs(2 * l - 1));
const h = mx === r ? 60 * (((g - b) / d) % 6) : mx === g ? 60 * ((b - r) / d + 2) : 60 * ((r - g) / d + 4);
return {h: (h + 360) % 360, s, l};
};
// 노랑 계열 글자 금지(밝은 바탕에서 안 읽히고 값싸 보인다 — 노랑은 면으로만)
const isYellow = (c: number[]) => { const x = hsl(c); return x.h >= 38 && x.h <= 72 && x.s > 0.45 && x.l > 0.42; };
const parseCss = (s: string) => (s.match(/[\d.]+/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
// 바탕 위에서 더 잘 읽히는 쪽
const bestOn = (bg: string, a: string, b: string) => (contrast(bg, a) >= contrast(bg, b) ? a : b);
// ── 글자 측정(캔버스, 실제 폰트) ──
const measureCtx = (() => { let g: CanvasRenderingContext2D | null = null; return () => (g ??= document.createElement('canvas').getContext('2d')!); })();
type Ink = {adv: number; l: number; r: number; asc: number; desc: number; fa: number; fd: number};
const ink = (text: string, font: string, track = 0): Ink => {
const g = measureCtx(); g.font = font; (g as any).letterSpacing = `${track}px`;
const m = g.measureText(text);
return {adv: m.width, l: -m.actualBoundingBoxLeft, r: m.actualBoundingBoxRight, asc: m.actualBoundingBoxAscent, desc: m.actualBoundingBoxDescent, fa: m.fontBoundingBoxAscent, fd: m.fontBoundingBoxDescent};
};
// 잉크 폭이 maxW 안에 들어가는 가장 큰 크기(max 부터 1px 씩 — 이분 탐색)
const fitSize = (text: string, font: (px: number) => string, maxW: number, max: number, trackEm = 0, maxInkH = Infinity) => {
let lo = 8, hi = max;
const ok = (px: number) => { const k = ink(text, font(px), trackEm * px); return k.r - k.l <= maxW && k.asc + k.desc <= maxInkH; };
if (ok(hi)) return hi;
while (hi - lo > 0.5) { const m = (lo + hi) / 2; if (ok(m)) lo = m; else hi = m; }
return Math.floor(lo);
};
// ── 줄 나누기(의미 단위) ──
// 낱말(빈칸 단위)은 절대 쪼개지 않는다(keep-all). 그리고 아래 자리에서는 줄을 바꾸지 않는다:
// 수 + 단위("삼백 권", "열다섯 집", "3 개"), 관형사 + 명사("한 송이", "이 책", "첫 출시")
// 조사·어미 없이 끝나는 낱말(맨 명사) 뒤는 다음 낱말과 한 덩어리일 때가 많아("도자기 병") 벌점, 조사·어미 뒤("골목에", "빚은", "굽고")는 괜찮다
const NUM_WORD = /^(\d[\d,.]*|[일이삼사오육칠팔구십백천만억]+|(열|스물|스무|서른|마흔|쉰|예순|일흔|여든|아흔)?(한|두|세|네|다섯|여섯|일곱|여덟|아홉|하나|둘|셋|넷)?)$/;
const DETERMINER = new Set(['이', '그', '저', '한', '두', '세', '네', '새', '헌', '첫', '옛', '온', '각', '몇', '여러', '모든', '갖은', '총', '매', '약', '단', '딱', '꼭', '단돈']);
const glued = (w: string) => w !== '' && (NUM_WORD.test(w) || DETERMINER.has(w));
const SOFT_END = /(에|에서|으로|로|까지|부터|고|며|면|서|게|지만|는데|니다|요|다|죠|와|과|랑|도|만|은|는|이|가|을|를|께|한테|처럼|보다|[,.!?·:])$/;
const breakCost = (w: string) => (glued(w) ? Infinity : SOFT_END.test(w) ? 0 : 4);
// 낱말 배열을 n 줄로 나누는 모든 방법(줄 수 2~3)과 의미 벌점. 금지 자리는 빠진다
const lineSplits = (s: string, maxLines = 2): {lines: string[]; cost: number}[] => {
const t = s.trim();
if (t.includes('|')) return [{lines: t.split('|').map((x) => x.trim()).filter(Boolean), cost: 0}];
const w = t.split(/\s+/), out: {lines: string[]; cost: number}[] = [{lines: [t], cost: 0}];
const orphan = (ls: string[]) => ls.reduce((a, l) => a + ([...l].length <= 2 && ls.length > 1 ? 6 : 0), 0);
for (let a = 1; a < w.length && maxLines >= 2; a++) {
const ls = [w.slice(0, a).join(' '), w.slice(a).join(' ')], c = breakCost(w[a - 1]);
if (c < Infinity) out.push({lines: ls, cost: c + orphan(ls)});
if (maxLines >= 3) for (let b = a + 1; b < w.length; b++) {
const l3 = [w.slice(0, a).join(' '), w.slice(a, b).join(' '), w.slice(b).join(' ')], c3 = breakCost(w[a - 1]) + breakCost(w[b - 1]);
if (c3 < Infinity) out.push({lines: l3, cost: c3 + orphan(l3)});
}
}
return out;
};
// 두 줄로: 글자 수 차이 + 의미 벌점이 가장 작은 곳(" | " 가 있으면 그대로)
const splitLines = (s: string, maxLines = 2): string[] => {
const c = lineSplits(s, maxLines).filter((x) => x.lines.length === Math.min(maxLines, Math.max(1, s.trim().split(/\s+/).length)) || s.includes('|'));
let best = c[0] ?? {lines: [s.trim()], cost: 0}, sc = Infinity;
for (const x of c) { const n = x.lines.map((l) => [...l].length); const v = Math.max(...n) - Math.min(...n) + x.cost * 1.5; if (v < sc) { sc = v; best = x; } }
return best.lines;
};
// 검사: 코드가 나눈 줄의 줄바꿈 자리가 금지 자리(수+단위·관형사+명사)인지
const breakIssues = (lines: string[]) => lines.slice(0, -1).map((l, k) => ({k, w: l.trim().split(/\s+/).at(-1) ?? ''})).filter((x) => glued(x.w)).map((x) => `"${lines[x.k]} / ${lines[x.k + 1]}" — "${x.w}" 뒤에서 줄이 바뀜(수+단위·관형사+명사는 한 줄에)`);
// 화면에 놓을 글자: 한 줄 · 잉크 기준 자리. data-k 가 붙은 글자는 검사(안전 영역·대비·노랑·넘침) 대상이다
// x = 잉크 왼쪽(align left) / 가운데(center) / 오른쪽(right), base = 기준선 y
type TextProps = {g?: number; inked?: boolean; over?: boolean; k: string; text: string; font: string; size: number; weight: number | string; color: string; bg: string; x: number; base: number; align?: 'left' | 'center' | 'right'; track?: number; minRatio?: number; glance?: boolean; style?: React.CSSProperties; hero?: boolean; check?: boolean};
const T: React.FC<TextProps> = ({g, inked, over, k, text, font, size, weight, color, bg, x, base, align = 'left', track = 0, minRatio = 4.5, glance = false, style, hero, check = true}) => {
const f = `${weight} ${size}px ${font}`;
const m = ink(text, f, track * size);
const left = align === 'left' ? x - m.l : align === 'center' ? x - (m.l + m.r) / 2 : x - m.r;
return (
<div data-k={check ? k : undefined} data-g={g} data-bg={bg} data-min={minRatio} data-glance={glance ? 1 : 0} data-hero={hero ? 1 : 0} data-over={over ? 1 : 0} data-text={text}
style={{position: 'absolute', left, top: base - m.fa, height: m.fa + m.fd, lineHeight: `${m.fa + m.fd}px`, whiteSpace: 'pre', fontFamily: font, fontWeight: weight, fontSize: size, letterSpacing: `${track * size}px`, color, ...(inked ? {filter: 'url(#ink)'} : {}), ...style}}>{text}</div>
);
};
// ── 종이(코드): 아주 낮은 주파수의 얼룩(±2~3%) + 느리게 흐르는 빛 + 가장자리 어둡게. 고른 노이즈·줄무늬·점은 넣지 않는다 ──
const rng = (seed: number) => () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const makeMottle = async (seed: number, texture?: string) => {
const c = document.createElement('canvas'); c.width = 270; c.height = 480;
const g = c.getContext('2d', {willReadFrequently: true})!;
if (texture) {
// 질감 판: 아주 작게 줄였다가 키워 낮은 주파수 명암만 빌린다(판 속 글자·점·결은 남지 않는다)
const i = await loadImage(texture);
const s = document.createElement('canvas'); s.width = 27; s.height = 48;
const sg = s.getContext('2d')!; const sc = Math.max(27 / i.width, 48 / i.height);
sg.drawImage(i, (27 - i.width * sc) / 2, (48 - i.height * sc) / 2, i.width * sc, i.height * sc);
g.imageSmoothingQuality = 'high'; g.filter = 'blur(10px) grayscale(1)'; g.drawImage(s, -20, -20, 310, 520);
} else {
const r = rng(seed); const s = document.createElement('canvas'); s.width = 9; s.height = 16;
const sg = s.getContext('2d')!;
for (let y = 0; y < 16; y++) for (let x = 0; x < 9; x++) { const v = Math.round(118 + r() * 20); sg.fillStyle = `rgb(${v},${v},${v})`; sg.fillRect(x, y, 1, 1); }
g.imageSmoothingQuality = 'high'; g.filter = 'blur(26px)'; g.drawImage(s, -40, -40, 350, 560);
}
// 평균 0.5, 폭 ±0.06 로 정규화
const d = g.getImageData(0, 0, 270, 480); let mean = 0, mn = 255, mx = 0;
for (let k = 0; k < d.data.length; k += 4) { mean += d.data[k]; mn = Math.min(mn, d.data[k]); mx = Math.max(mx, d.data[k]); }
mean /= d.data.length / 4; const span = Math.max(4, mx - mn);
for (let k = 0; k < d.data.length; k += 4) { const v = 128 + ((d.data[k] - mean) / span) * 30; d.data[k] = d.data[k + 1] = d.data[k + 2] = v; d.data[k + 3] = 255; }
g.filter = 'none'; g.putImageData(d, 0, 0);
return c.toDataURL('image/png');
};
// 종이 결(코드, 고정 — 프레임마다 바뀌지 않는다): 540×960 판(검정·흰 반투명)을 종이 위에 얹는다.
// ① 이(tooth): 2px 단위 아주 작은 요철 ±6단계 ② 섬유: 짧은 곡선 1,400개(길이 10~46px, 밝게·어둡게 ±6~14단계, 점 없음)
// 검정·흰 반투명으로 바꿔 일반 합성(밝은 종이·어두운 면 모두 같은 세기). 세기는 아주 낮은 주파수 장(0.35~1.45배)으로 영역마다 다르다 — 한 장 전체에 같은 필터를 씌운 티를 피한다(14차 교훈: 고른 노이즈·줄무늬·점 금지).
// 잉크(글자·면) 아래에만 깐다. 움직이는 빛·흐린 얼룩 조명은 쓰지 않는다(19차 검수: "흐린 얼룩 조명 = 렌더 티")
const makeFiber = (seed: number) => {
const w = 540, h = 960, r = rng(seed * 31 + 5);
const lf = document.createElement('canvas'); lf.width = 6; lf.height = 10;
const lg = lf.getContext('2d')!;
for (let y = 0; y < 10; y++) for (let x = 0; x < 6; x++) { const v = Math.round(r() * 255); lg.fillStyle = `rgb(${v},${v},${v})`; lg.fillRect(x, y, 1, 1); }
const big = document.createElement('canvas'); big.width = w; big.height = h;
const bg = big.getContext('2d', {willReadFrequently: true})!; bg.imageSmoothingQuality = 'high'; bg.filter = 'blur(40px)'; bg.drawImage(lf, -60, -60, w + 120, h + 120);
const L = bg.getImageData(0, 0, w, h).data;
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d', {willReadFrequently: true})!;
const d = g.createImageData(w, h);
for (let y = 0; y < h; y += 2) for (let x = 0; x < w; x += 2) {
const k = 0.35 + 1.1 * (L[(y * w + x) * 4] / 255), v = 128 + (r() - 0.5) * 12 * k;
for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) { const q = ((y + dy) * w + x + dx) * 4; d.data[q] = d.data[q + 1] = d.data[q + 2] = v; d.data[q + 3] = 255; }
}
g.putImageData(d, 0, 0);
g.filter = 'blur(0.5px)'; g.drawImage(c, 0, 0); g.filter = 'none';
g.lineCap = 'round';
for (let n = 0; n < 1400; n++) {
const x = r() * w, y = r() * h, k = 0.35 + 1.1 * (L[(Math.floor(y) * w + Math.floor(x)) * 4] / 255);
const len = 10 + r() * 36, a0 = r() * Math.PI, bend = (r() - 0.5) * 0.8, lite = r() < 0.55;
const dv = Math.round((6 + r() * 8) * k) * (lite ? 1 : -1);
g.strokeStyle = `rgba(${128 + dv},${128 + dv},${128 + dv},${0.5 + r() * 0.4})`; g.lineWidth = 0.4 + r() * 0.6;
g.beginPath(); g.moveTo(x, y);
g.quadraticCurveTo(x + Math.cos(a0 + bend) * len * 0.5, y + Math.sin(a0 + bend) * len * 0.5, x + Math.cos(a0) * len, y + Math.sin(a0) * len); g.stroke();
}
// 회색 판 → 검정·흰 반투명(일반 합성): overlay 는 밝은 종이에서 거의 안 보인다 — 밝은 종이·어두운 면 모두에서 같은 세기로 보이게
const d2 = g.getImageData(0, 0, w, h);
for (let k = 0; k < d2.data.length; k += 4) { const v = d2.data[k] - 128; d2.data[k] = d2.data[k + 1] = d2.data[k + 2] = v > 0 ? 255 : 0; d2.data[k + 3] = Math.min(255, Math.abs(v) * 2); }
g.putImageData(d2, 0, 0);
return c.toDataURL('image/png');
};
// 종이 한 장(바탕색 + 아주 낮은 주파수 얼룩 + 고정 결 + 가장자리 아주 조금 어둡게). x·y·w·h 를 주면 그 상자 안(종이 장 한 장)
const Paper: React.FC<{color: string; mottle: string | null; fiber?: string | null; frame?: number; fiberOpacity?: number; vignette?: number; box?: {l: number; t: number; w: number; h: number}}> = ({color, mottle, fiber, fiberOpacity = 0.6, vignette = 0.06, box}) => {
const b = box ?? {l: 0, t: 0, w: W, h: H};
return (
<div style={{position: 'absolute', left: b.l, top: b.t, width: b.w, height: b.h, backgroundColor: color, overflow: 'hidden'}}>
{mottle && <Img src={mottle} style={{position: 'absolute', left: -b.l, top: -b.t, width: W, height: H, mixBlendMode: 'soft-light', opacity: 0.45}} />}
{fiber && <Img src={fiber} style={{position: 'absolute', left: -b.l, top: -b.t, width: W, height: H, opacity: fiberOpacity}} />}
{vignette > 0 && <div style={{position: 'absolute', inset: 0, background: `radial-gradient(ellipse 90% 75% at 50% 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,${vignette}) 100%)`}} />}
</div>
);
};
// 인쇄 잉크 필터(id = ink): 가장자리만 아주 조금 거칠게(변위 1.4px, 고정 시드). 잉크 면 안쪽은 불투명·고르게(14차 교훈: 면 안 고운 얼룩·점 금지)
// id = inkFace: 큰 면(원·막대·띠)용 — 같은 거칠기 + 실크스크린 판 어긋남은 요소 스타일(misreg)로
const InkDefs: React.FC<{seed?: number}> = ({seed = 3}) => (
<svg width={0} height={0} style={{position: 'absolute'}}>
<filter id="ink" x="-3%" y="-3%" width="106%" height="106%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.55" numOctaves={2} seed={seed} result="e" />
<feDisplacementMap in="SourceGraphic" in2="e" scale={1.4} xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id="inkFace" x="-2%" y="-2%" width="104%" height="104%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves={3} seed={seed + 4} result="e" />
<feDisplacementMap in="SourceGraphic" in2="e" scale={3.2} xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
);
// 판 어긋남: 면 아래로 먹색 1.5px 비껴 찍힌 얇은 자국(투명도 0.22) — 두 판이 어긋나 찍힌 실크스크린 자국
const misreg = (inkHex: string, a = 0.22) => { const [r, g, b] = hexRgb(inkHex); return `drop-shadow(-1.5px 1px 0 rgba(${r},${g},${b},${a}))`; };
// ── 검사: 대표 프레임(컷 60%)과 0프레임에서 data-k 글자의 실제 잉크 상자를 재서 보고한다 ──
const effOpacity = (el: HTMLElement | null) => { let o = 1; while (el) { const v = parseFloat(getComputedStyle(el).opacity); if (!Number.isNaN(v)) o *= v; el = el.parentElement; } return o; };
const inkRect = (el: HTMLElement) => {
const r = el.getBoundingClientRect(), cs = getComputedStyle(el);
const text = el.dataset.text ?? el.textContent ?? '';
const m = ink(text, `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`, parseFloat(cs.letterSpacing) || 0);
const k = el.offsetWidth ? r.width / el.offsetWidth : 1;
const lh = parseFloat(cs.lineHeight) || m.fa + m.fd;
const base = (lh - (m.fa + m.fd)) / 2 + m.fa;
return {l: r.left + m.l * k, r: r.left + m.r * k, t: r.top + (base - m.asc) * k, b: r.top + (base + m.desc) * k, size: parseFloat(cs.fontSize) * k};
};
// mask = 사진(제품)이 차지한 자리(270×480 격자, 1 = 사진). data-over="1" 이 아닌 글자가 사진 위에 3% 넘게 놓이면 실패
// 그래픽 ↔ 글자 겹침: data-gfx(해·햇살·원·고리·시계·선·윤곽 숫자처럼 글자가 아닌 그래픽)가 글자 잉크 상자(+여백 12px)에 걸리면 실패.
// 먼저 상자끼리 겹치는지 보고, 겹칠 때만 6px 간격 점을 elementsFromPoint 로 찍어 실제 모양(둥근 면·SVG 선의 칠해진 곳)으로 잰다.
// 대표 프레임 검사(runCheck)와 MP4 렌더 중 모든 프레임(scanGfx — 해가 컷 끝에 가장 높이 뜨는 것처럼 컷 안에서 움직이는 그래픽)에서 쓴다
const gfxHits = (root: HTMLElement) => {
const gfx = [...root.querySelectorAll<Element>('[data-gfx]')].filter((g) => effOpacity(g as HTMLElement) > 0.05).map((g) => ({g, r: g.getBoundingClientRect()}));
const out: string[] = [];
if (!gfx.length) return out;
root.querySelectorAll<HTMLElement>('[data-k]').forEach((el) => {
if (effOpacity(el) < 0.05) return;
const b = visBox(el); if (b.r - b.l < 2 || b.b - b.t < 2) return; // 잘라 내는 부모(마스크) 밖으로 숨은 부분은 빼고 보이는 잉크만
const P = 12, l = b.l - P, r = b.r + P, t = b.t - P, bt = b.b + P;
if (!gfx.some((x) => x.r.right > l && x.r.left < r && x.r.bottom > t && x.r.top < bt)) return;
let hit = '';
const nx = Math.max(2, Math.ceil((r - l) / 6)), ny = Math.max(2, Math.ceil((bt - t) / 6));
for (let y = 0; y < ny && !hit; y++) for (let x = 0; x < nx && !hit; x++) {
const px = l + (r - l) * (x + 0.5) / nx, py = t + (bt - t) * (y + 0.5) / ny;
if (px < 0 || py < 0 || px >= W || py >= H) continue;
for (const e of document.elementsFromPoint(px, py)) { const g = (e as HTMLElement).closest?.('[data-gfx]') as HTMLElement | null; if (g && effOpacity(g) > 0.05) { hit = g.dataset.gfx ?? 'gfx'; break; } }
}
if (hit) out.push(`${el.dataset.k} "${el.dataset.text}" 잉크(+12px)에 그래픽 "${hit}" 이 걸림 — 코드 배치 문제`);
});
return out;
};
const scanGfx = (root: HTMLElement, frame: number) => { const h = gfxHits(root); if (h.length) console.log('DUCHAMP_GFX ' + JSON.stringify({frame, hits: h})); };
type PhotoMask = {w: number; h: number; m: Uint8Array};
const runCheck = (root: HTMLElement, cut: number, frame: number, extra: Record<string, unknown> = {}, mask: PhotoMask | null = null) => {
const over: string[] = [], hint: string[] = [], items: any[] = [];
root.querySelectorAll<HTMLElement>('[data-k]').forEach((el) => {
const op = effOpacity(el); if (op < 0.05) return;
const b = inkRect(el), fg = parseCss(getComputedStyle(el).color), bg = el.dataset.bg ?? '#ffffff';
const ratio = contrast(fg, bg), need = parseFloat(el.dataset.min ?? '4.5');
const it = {k: el.dataset.k, text: el.dataset.text, size: +b.size.toFixed(1), ink: [b.l, b.t, b.r, b.b].map((v) => Math.round(v)), color: rgbHex(fg), bg, ratio: +ratio.toFixed(2), opacity: +op.toFixed(2)};
items.push(it);
if (b.t < SAFE_TOP - 1 || b.b > SAFE_BOTTOM + 1 || b.l < SIDE - 2 || b.r > W - SIDE + 2) { over.push('safe-area'); hint.push(`${it.k} "${it.text}" 잉크 ${it.ink.join(',')} 가 안전 영역(위 ${SAFE_TOP}·아래 ${SAFE_BOTTOM}·좌우 ${SIDE}) 밖`); }
if (ratio + 1e-6 < need) { over.push('contrast'); hint.push(`${it.k} "${it.text}" 대비 ${ratio.toFixed(2)} < ${need} (${it.color} / ${bg}) — 브랜드 색 조합을 바꾼다`); }
if (mask && el.dataset.over !== '1') {
const sx = mask.w / W, sy = mask.h / H; let hit = 0, all = 0;
for (let y = Math.max(0, Math.floor(b.t * sy)); y < Math.min(mask.h, Math.ceil(b.b * sy)); y++) for (let x = Math.max(0, Math.floor(b.l * sx)); x < Math.min(mask.w, Math.ceil(b.r * sx)); x++) { all++; hit += mask.m[y * mask.w + x]; }
const fr = all ? hit / all : 0; (it as any).onPhoto = +fr.toFixed(3);
if (fr > 0.03) { over.push('text-on-photo'); hint.push(`${it.k} "${it.text}" 가 제품 사진 위에 ${(fr * 100).toFixed(0)}% 놓임 — 코드 배치 문제(사진 비율이 아주 넓거나 높으면 다른 사진)`); }
}
if (isYellow(fg)) { over.push('yellow-text'); hint.push(`${it.k} 글자색 ${it.color} 가 노랑 계열 — 노랑은 면으로만 쓴다(포인트색을 면으로 돌리거나 다른 색)`); }
});
for (const g of gfxHits(root)) { over.push('gfx-collide'); hint.push(g); }
// 글자끼리 겹침(잉크 상자, 2px 넘게)
for (let i = 0; i < items.length; i++) for (let j = i + 1; j < items.length; j++) {
const a = items[i].ink, b = items[j].ink;
const ox = Math.min(a[2], b[2]) - Math.max(a[0], b[0]), oy = Math.min(a[3], b[3]) - Math.max(a[1], b[1]);
if (ox > 2 && oy > 2) { over.push('text-collide'); hint.push(`${items[i].k} 과 ${items[j].k} 잉크가 겹침`); }
}
report({cut, frame, items, overflow: [...new Set(over)], hint, ...extra});
};
// 훅: 주인공 글자(data-hero)가 0.3초(9프레임) 안에 크게·불투명하게 들어와 있어야 한다(hook).
// 0프레임에 이미 완성된 정지 화면이면 안 된다 — 0프레임과 9프레임의 화면 차이는 render.mjs 가 잰다(hook-static)
const HOOK_F = 9;
const runHook = (root: HTMLElement, frame: number) => {
const heroes = [...root.querySelectorAll<HTMLElement>('[data-hero="1"]')].map((el) => { const b = inkRect(el); return {k: el.dataset.k, opacity: +effOpacity(el).toFixed(2), inside: b.l >= -2 && b.r <= W + 2 && b.t >= -2 && b.b <= H + 2, area: Math.round(Math.max(0, b.r - b.l) * Math.max(0, b.b - b.t))}; });
const ok = heroes.length > 0 && heroes.some((h) => h.opacity >= 0.9 && h.inside && h.area > 40000);
report({hook: true, frame, heroes, overflow: ok ? [] : ['hook'], hint: ok ? [] : ['0.3초 안에 주인공 글자가 크게·불투명하게 들어오지 않음(코드 문제)']});
};
// 전환 겹침: data-g(컷 번호)가 다른 글자 둘이 같은 프레임에 같은 자리에 보이면(위에 불투명한 종이가 덮지 않은 채) 겹침.
// 글자 잉크 상자(+ 잘라 내는 부모 상자) 교집합을 6×6 점으로 찍어 elementsFromPoint 로 실제로 둘 다 보이는지 본다. 모든 프레임에서 잰다(MP4 렌더 중)
const visBox = (el: HTMLElement) => {
const b = inkRect(el); let l = Math.max(0, b.l), t = Math.max(0, b.t), r = Math.min(W, b.r), bt = Math.min(H, b.b);
for (let a = el.parentElement; a; a = a.parentElement) { const cs = getComputedStyle(a); if (cs.overflow === 'hidden' || cs.clipPath !== 'none') { const q = a.getBoundingClientRect(); l = Math.max(l, q.left); t = Math.max(t, q.top); r = Math.min(r, q.right); bt = Math.min(bt, q.bottom); } }
return {l, t, r, b: bt};
};
const opaqueBlock = (el: Element) => { if (!(el instanceof HTMLElement)) return false; const c = getComputedStyle(el).backgroundColor.match(/[\d.]+/g); return !!c && (c.length < 4 || +c[3] >= 0.95) && effOpacity(el) >= 0.95; };
const scanOverlap = (root: HTMLElement, frame: number) => {
const els = [...root.querySelectorAll<HTMLElement>('[data-g]')].filter((el) => effOpacity(el) > 0.03).map((el) => ({el, g: el.dataset.g!, b: visBox(el)})).filter((x) => x.b.r - x.b.l > 1 && x.b.b - x.b.t > 1);
const pairs: string[] = [];
for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) {
const A = els[i], B = els[j]; if (A.g === B.g) continue;
const l = Math.max(A.b.l, B.b.l), r = Math.min(A.b.r, B.b.r), t = Math.max(A.b.t, B.b.t), b = Math.min(A.b.b, B.b.b);
if (r - l < 2 || b - t < 2) continue;
let both = false;
for (let y = 0; y < 6 && !both; y++) for (let x = 0; x < 6 && !both; x++) {
const st = document.elementsFromPoint(l + (r - l) * (x + 0.5) / 6, t + (b - t) * (y + 0.5) / 6); let sa = false, sb = false;
for (const e of st) { if (e === A.el) sa = true; else if (e === B.el) sb = true; else if (opaqueBlock(e) && !e.contains(A.el) && !e.contains(B.el)) break; if (sa && sb) { both = true; break; } }
}
if (both) pairs.push(`${A.el.dataset.text}(${A.g}) × ${B.el.dataset.text}(${B.g})`);
}
if (pairs.length) console.log('DUCHAMP_OVL ' + JSON.stringify({frame, pairs}));
};
// ── 등록: 본문은 Body(p, frame, ctx) 만 만든다. 공용이 폰트·종이·시간표·검사를 맡는다 ──
type Setup<P, L> = (p: P) => Promise<L>;
type Spec<P extends {cuts: CutBase[]}, L> = {
slug: string; minCuts: number; maxCuts: number; minSec: number; maxSec: number; cutMin: number; cutMax: number;
fonts: (p: P) => {css: string[]; specs: [string, string][]};
setup: Setup<P, L>;
paper: (p: P) => string; // 종이 바탕색(컷마다 면을 바꾸는 템플릿은 본문이 위에 덮는다)
texture?: (p: P) => string | undefined;
readChars: (p: P, i: number) => number; // 컷마다 읽어야 하는 글자 수(한눈 글자 제외)
Body: React.FC<{p: P; L: L; frame: number; tl: {start: number; len: number}[]; mottle: string | null; fiber: string | null}>;
extraCheck?: (p: P, L: L, frame: number) => Record<string, unknown>;
photoMask?: (p: P, L: L, cut: number) => PhotoMask | null;
};
function registerVideo<P extends {cuts: CutBase[]}, L>(spec: Spec<P, L>, defaults: P) {
const Main: React.FC<P> = (p) => {
const [handle] = useState(() => delayRender('fonts+layout', {timeoutInMilliseconds: 120000}));
const [L, setL] = useState<L | null>(null);
const [mottle, setMottle] = useState<string | null>(null);
const [fiber, setFiber] = useState<string | null>(null);
const [pre, setPre] = useState<string[]>([]);
const root = useRef<HTMLDivElement>(null);
const frame = useCurrentFrame();
const tl = timeline(p.cuts);
useEffect(() => {
(async () => {
const over: string[] = [], hint: string[] = [];
const n = p.cuts.length, total = p.cuts.reduce((s, c) => s + c.sec, 0);
if (n < spec.minCuts || n > spec.maxCuts) { over.push('cut-count'); hint.push(`컷 ${n}개 — ${spec.minCuts}~${spec.maxCuts}개`); }
if (total < spec.minSec - 1e-6 || total > spec.maxSec + 1e-6) { over.push('cut-length'); hint.push(`합계 ${total.toFixed(2)}초 — ${spec.minSec}~${spec.maxSec}초`); }
p.cuts.forEach((c, i) => {
if (c.sec < spec.cutMin - 1e-6 || c.sec > spec.cutMax + 1e-6) { over.push('cut-length'); hint.push(`컷 ${i + 1} ${c.sec}초 — 컷마다 ${spec.cutMin}~${spec.cutMax}초`); }
const need = 0.8 + 0.07 * spec.readChars(p, i);
if (c.sec + 1e-6 < need) { over.push('read-time'); hint.push(`컷 ${i + 1}: 읽을 글자에 ${need.toFixed(1)}초가 필요한데 ${c.sec}초 — 글자를 줄이거나 컷을 늘린다`); }
});
setPre([...over, ...hint.map((h) => 'HINT:' + h)]);
const f = spec.fonts(p);
await loadFonts(f.css, f.specs);
const lay = await spec.setup(p);
setMottle(await makeMottle(7, spec.texture?.(p) ? staticFile(spec.texture(p)!) : undefined));
setFiber(makeFiber(11));
setL(lay);
await settle();
continueRender(handle);
})().catch((e) => cancelRender(e));
}, []);
// 대표 프레임·0프레임이면 그려진 뒤 잰다
useLayoutEffect(() => {
if (!L || !root.current) return;
const ci = tl.findIndex((t) => frame === restFrame(t));
if (frame === HOOK_F) runHook(root.current, frame);
scanOverlap(root.current, frame);
scanGfx(root.current, frame);
if (ci >= 0) {
const preOver = pre.filter((x) => !x.startsWith('HINT:')), preHint = pre.filter((x) => x.startsWith('HINT:')).map((x) => x.slice(5));
const ex = spec.extraCheck?.(p, L, frame) ?? {};
const exOver = (ex.overflow as string[] | undefined) ?? [], exHint = (ex.hint as string[] | undefined) ?? [];
runCheck(root.current, ci + 1, frame, {pre: {overflow: [...preOver, ...exOver], hint: [...preHint, ...exHint]}, info: ex.info ?? null, video: {cuts: p.cuts.length, totalSec: +p.cuts.reduce((s, c) => s + c.sec, 0).toFixed(2), frames: totalFrames(p.cuts), fps: FPS, size: `${W}x${H}`, safe: {top: SAFE_TOP, bottom: SAFE_BOTTOM, side: SIDE}, rest: tl.map(restFrame)}}, spec.photoMask?.(p, L, ci) ?? null);
}
});
return (
<AbsoluteFill ref={root} style={{backgroundColor: spec.paper(p), overflow: 'hidden'}}>
<InkDefs />
{L && <spec.Body p={p} L={L} frame={frame} tl={tl} mottle={mottle} fiber={fiber} />}
</AbsoluteFill>
);
};
registerRoot(() => <Composition id="Main" component={Main as any} width={W} height={H} fps={FPS} durationInFrames={totalFrames(defaults.cuts)} defaultProps={defaults as any}
calculateMetadata={({props}) => ({durationInFrames: totalFrames((props as unknown as P).cuts)})} />);
}
// 컷 안 시간: i = 컷 번호(0부터), f = 컷 시작부터 프레임, u = 0~1
const cutAt = (frame: number, tl: {start: number; len: number}[]) => {
let i = tl.findIndex((t) => frame >= t.start && frame < t.start + t.len); if (i < 0) i = frame < 0 ? 0 : tl.length - 1;
const f = frame - tl[i].start; return {i, f, u: f / tl[i].len, len: tl[i].len};
};
// ── Duchamp 코드 레시피 · change-notice-video (변경 안내 · 장부 체계 · 9:16 · 컷 3~5 · 8~12초) ──
// 종이 장부(옅은 가로 괘선 + 왼쪽 포인트색 여백선). 위 머리줄: 브랜드 · 바뀌는 날.
// 1 표지: 초대형 날짜(Bodoni Moda, 포인트색) + 제목 + 바뀌는 항목 목록이 한 줄씩 적힘
// 2~ 항목마다: 큰 번호 + 항목 이름 → "전" 값에 줄이 그어지고 → "후" 값이 아래에서 올라와 형광 띠가 깔리고 → 체크가 그려진다
// 마지막: 바뀐 값만 모은 목록(체크가 하나씩) + 한 줄 + 브랜드. 컷 안에서 장부가 천천히 위로 흐른다.
type CnItem = {label: string; before: string; after: string};
type CnCut = {sec: number; big?: string | null; line?: string | null; item?: CnItem | null};
type CnProps = {brand: string; colors: {paper: string; ink: string; accent: string}; cuts: CnCut[]; texture?: string};
const CN_FONTS = ['https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Noto+Serif+KR:wght@500;700;900&display=block'];
const BOD = '"Abril Fatface"', NSK = '"Noto Serif KR"';
const CL = 96, CR = 1008, CW = CR - CL, MARGIN_X = 70;
type CnLayout = {date: string; items: CnItem[];
cover: {px: number; base: number; titlePx: number; titleBase: number; listPx: number; listBases: number[]};
pages: {v: number; numPx: number; numBase: number; labelPx: number; labelX: number; beforePx: number; beforeBase: number; afterPx: number; afterBase: number}[];
sum: {labelPx: number; valPx: number; rows: {labelBase: number; valBase: number}[]; linePx: number; lineBase: number; brandPx: number; brandBase: number};
markColor: string; textOnMark: string};
const cnSetup = async (p: CnProps): Promise<CnLayout> => {
const date = (p.cuts[0].big ?? '').trim(), title = (p.cuts[0].line ?? '').trim();
const items = p.cuts.slice(1, -1).map((c) => c.item!).filter(Boolean);
// 표지
const px = fitSize(date, (q) => `400 ${q}px ${BOD}`, CW / 1.04, 560, 0.01, 400), dm = ink(date, `400 ${px}px ${BOD}`, 0.01 * px);
const base = 404 + dm.asc;
const titlePx = title ? Math.min(96, fitSize(title, (q) => `700 ${q}px ${NSK}`, CW, 96)) : 96;
const titleBase = base + dm.desc + 40 + titlePx * 0.86;
const listPx = Math.min(52, ...items.map((it) => fitSize(`00 ${it.label}`, (q) => `500 ${q}px ${NSK}`, CW - 40, 52)));
const listTop = titleBase + 70, listStep = Math.min(96, (SAFE_BOTTOM - 30 - listTop) / Math.max(1, items.length));
const listBases = items.map((_, k) => listTop + listStep * (k + 0.72));
// 항목 컷
// 항목 컷은 번갈아 배치가 바뀐다: v0 = 번호 왼쪽·이름 오른쪽 / v1 = 이름 왼쪽·번호 오른쪽, 후 값이 더 크게(짧은 값은 폭을 채우도록 키운다)
const pages = items.map((it, k) => {
const v = k % 2, numPx = 400, nm = ink('00', `400 ${numPx}px ${BOD}`, 0.02 * numPx), numBase = 410 + nm.asc;
const labelX = v ? CL : CL + (nm.r - nm.l) + 44;
const labelPx = Math.min(92, fitSize(it.label, (q) => `700 ${q}px ${NSK}`, v ? CW - (nm.r - nm.l) - 44 : CR - labelX, 92));
const beforePx = Math.min(66, fitSize(it.before, (q) => `500 ${q}px ${NSK}`, CW - 120, 66));
const afterPx = Math.min(v ? 200 : 160, fitSize(it.after, (q) => `700 ${q}px ${NSK}`, CW - 120, 200, -0.02));
const beforeBase = numBase + 210;
return {v, numPx, numBase, labelPx, labelX, beforePx, beforeBase, afterPx, afterBase: Math.min(SAFE_BOTTOM - 40, beforeBase + 170 + afterPx * 0.86)};
});
// 마지막
const n = Math.max(1, items.length);
// 모아 보기: 브랜드를 안전 영역 아래 끝에 앉히고, 그 위에 한 줄, 남는 높이를 항목 줄이 고르게 나눠 쓴다(아래가 비지 않게)
const line = (p.cuts.at(-1)!.line ?? '').trim();
const linePx = line ? Math.min(54, fitSize(line, (q) => `500 ${q}px ${NSK}`, CW, 54)) : 54;
const brandPx = Math.min(124, fitSize(p.brand, (q) => `900 ${q}px ${NSK}`, CW, 124));
const brandBase = SAFE_BOTTOM - 24 - brandPx * 0.12;
const lineBase = brandBase - brandPx * 0.86 - 78;
const rowsB = lineBase - linePx - 70, rowH = Math.min(230, (rowsB - 410) / n);
const labelPx = Math.min(40, rowH * 0.2), valPx = Math.min(rowH * 0.5, 96, ...items.map((it) => fitSize(it.after, (q) => `700 ${q}px ${NSK}`, CW - 110, 96)));
const rows = items.map((_, k) => { const top = 410 + k * rowH + (rowH - labelPx - 22 - valPx * 0.86 - 30) / 2; return {labelBase: top + labelPx, valBase: top + labelPx + 22 + valPx * 0.86}; });
// 형광 띠: 포인트색이 진하면 종이 쪽으로 옅게(글자 대비 4.5 이상이 나올 때까지)
let markColor = p.colors.accent, tt = 0;
while (contrast(markColor, p.colors.ink) < 4.5 && tt < 0.9) { tt += 0.05; markColor = mix(p.colors.accent, p.colors.paper, tt); }
return {date, items, cover: {px, base, titlePx, titleBase, listPx, listBases}, pages, sum: {labelPx, valPx, rows, linePx, lineBase, brandPx, brandBase}, markColor, textOnMark: p.colors.ink};
};
const Check: React.FC<{x: number; y: number; s: number; t: number; color: string}> = ({x, y, s, t, color}) => (
<svg width={s} height={s} viewBox="0 0 100 100" style={{position: 'absolute', left: x, top: y, overflow: 'visible'}}>
<circle cx={50} cy={50} r={44} fill="none" stroke={color} strokeWidth={8} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - clamp01(t * 1.6)} transform="rotate(-90 50 50)" data-gfx="check" />
<path d="M 29 52 L 44 66 L 72 36" fill="none" stroke={color} strokeWidth={10} strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - clamp01((t - 0.45) * 2)} />
</svg>
);
const CnBody: React.FC<{p: CnProps; L: CnLayout; frame: number; tl: {start: number; len: number}[]; mottle: string | null; fiber: string | null}> = ({p, L, frame, tl, mottle, fiber}) => {
const {i, f, len} = cutAt(frame, tl);
const {paper, ink: inkC, accent} = p.colors;
const n = p.cuts.length, u = f / len;
const last = i === n - 1, cover = i === 0;
const drift = -22 * u; // 장부가 컷 안에서 천천히 위로
const IN_F = 7;
const enterA = cover ? 1 : ramp(f, IN_F, IN_F + 6);
const enterY = cover ? 0 : (1 - ramp(f, IN_F, IN_F + 12, SNAP)) * 140;
const ruleLines = Array.from({length: 22}, (_, q) => q);
const markC = L.markColor;
const accentText = contrast(paper, accent) >= 3 && !isYellow(hexRgb(accent)) ? accent : inkC;
const item = !cover && !last ? L.items[i - 1] : null, pg = !cover && !last ? L.pages[i - 1] : null;
// 컷 내용(ci 번째 컷, 그 컷 안 프레임 ff). 다음 컷이 시작하면 앞 내용은 위로 빠지며 사라지고 새 내용이 아래에서 올라온다
const content = (ci: number, ff: number, dy: number, op: number) => {
const f = ff, len = tl[ci].len, u = f / len, cover = ci === 0, last = ci === n - 1;
const item = !cover && !last ? L.items[ci - 1] : null, pg = !cover && !last ? L.pages[ci - 1] : null;
return (
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, transform: `translateY(${dy}px)`, opacity: op}}>
{cover && <>
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: `${CL}px ${L.cover.base}px`, transform: `scale(${lerp(1.35, 1, ramp(f, 0, 7, BACK)) * (1 + 0.03 * u)})`, opacity: ramp(f, 0, 3, Easing.linear)}}>
<T k="date" g={ci} inked hero glance text={L.date} font={BOD} weight={400} size={L.cover.px} color={accentText} bg={paper} x={CL} base={L.cover.base} track={0.01} minRatio={3} />
</div>
{p.cuts[0].line && <div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: ramp(f, 4, 14), transform: `translateY(${(1 - ramp(f, 4, 16)) * 26}px)`}}>
<T k="title" g={ci} text={p.cuts[0].line!} font={NSK} weight={700} size={L.cover.titlePx} color={inkC} bg={paper} x={CL} base={L.cover.titleBase} track={-0.02} />
</div>}
{L.items.map((it, q) => { const a = ramp(f, 12 + q * 7, 22 + q * 7); return (
<div key={q} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: a}}>
<div style={{position: 'absolute', left: CL, top: L.cover.listBases[q] + 18, width: CW * a, height: 2, backgroundColor: inkC, opacity: 0.6}} />
<T k={`list${q}`} g={ci} text={String(q + 1).padStart(2, '0')} font={BOD} weight={400} size={L.cover.listPx} color={accentText} bg={paper} x={CL} base={L.cover.listBases[q]} minRatio={3} />
<T k={`listl${q}`} g={ci} text={it.label} font={NSK} weight={500} size={L.cover.listPx} color={inkC} bg={paper} x={CL + L.cover.listPx * 2.1} base={L.cover.listBases[q]} />
</div>); })}
</>}
{item && pg && (() => {
const strike = ramp(f, 10, 20, Easing.bezier(0.6, 0, 0.3, 1));
const afA = ramp(f, 18, 30), afY = (1 - ramp(f, 18, 32, SNAP)) * 30;
const mark = ramp(f, 26, 38, Easing.bezier(0.3, 0, 0.2, 1));
const am = ink(item.after, `700 ${pg.afterPx}px ${NSK}`, -0.02 * pg.afterPx), bm = ink(item.before, `500 ${pg.beforePx}px ${NSK}`, 0);
const tagX = CL, valX = CL + 112;
const numS = 1 + 0.05 * u; // 번호가 컷 동안 조금씩 다가온다
return <>
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: `${pg.v ? CR : CL}px ${pg.numBase}px`, transform: `scale(${numS})`}}>
<T k="num" g={ci} inked glance text={String(ci).padStart(2, '0')} font={BOD} weight={400} size={pg.numPx} color={accentText} bg={paper} x={pg.v ? CR : CL} align={pg.v ? 'right' : 'left'} base={pg.numBase} track={0.02} minRatio={3} />
</div>
<T k="label" g={ci} text={item.label} font={NSK} weight={700} size={pg.labelPx} color={inkC} bg={paper} x={pg.labelX} base={pg.numBase} track={-0.02} />
{/* 전 */}
<T k="tagB" g={ci} text="전" font={NSK} weight={700} size={36} color={inkC} bg={paper} x={tagX} base={pg.beforeBase} />
<T k="before" g={ci} text={item.before} font={NSK} weight={500} size={pg.beforePx} color={inkC} bg={paper} x={valX} base={pg.beforeBase} />
<div style={{position: 'absolute', left: valX - 8, top: pg.beforeBase - bm.asc * 0.42 - 4, width: (bm.r - bm.l + 16) * strike, height: 8, backgroundColor: accent, borderRadius: '4px 2px 3px 5px', transform: 'rotate(-0.6deg)', filter: 'url(#inkFace)'}} />
{/* 화살표 */}
<svg width={60} height={200} style={{position: 'absolute', left: tagX + 2, top: pg.beforeBase + 34, overflow: 'visible'}}>
<path d={`M 16 0 L 16 ${pg.afterBase - pg.beforeBase - pg.afterPx * 0.3 - 96} M 2 ${pg.afterBase - pg.beforeBase - pg.afterPx * 0.3 - 110} L 16 ${pg.afterBase - pg.beforeBase - pg.afterPx * 0.3 - 94} L 30 ${pg.afterBase - pg.beforeBase - pg.afterPx * 0.3 - 110}`} fill="none" stroke={inkC} strokeWidth={4} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(f, 14, 24)} />
</svg>
{/* 후 */}
<div style={{position: 'absolute', left: valX - 14, top: pg.afterBase - am.asc * 0.52, width: (am.r - am.l + 28) * mark, height: am.asc * 0.64, backgroundColor: markC, borderRadius: '10px 4px 12px 6px', transform: 'rotate(-0.8deg) skewX(-6deg)', filter: 'url(#inkFace)', opacity: 0.92}} />
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: afA, transform: `translateY(${afY}px)`}}>
<T k="tagA" g={ci} text="후" font={NSK} weight={700} size={36} color={inkC} bg={paper} x={tagX} base={pg.afterBase - pg.afterPx * 0.3} />
<T k="after" g={ci} text={item.after} font={NSK} weight={700} size={pg.afterPx} color={inkC} bg={paper} x={valX} base={pg.afterBase} track={-0.02} />
</div>
</>;
})()}
{last && <>
{L.items.map((it, q) => { const a = ramp(f, IN_F + q * 7, IN_F + 12 + q * 7); return (
<div key={q} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: a, transform: `translateX(${(1 - a) * 40}px)`}}>
<T k={`sl${q}`} g={ci} text={`${String(q + 1).padStart(2, '0')} ${it.label}`} font={NSK} weight={500} size={L.sum.labelPx} color={inkC} bg={paper} x={CL} base={L.sum.rows[q].labelBase} />
<T k={`sv${q}`} g={ci} text={it.after} font={NSK} weight={700} size={L.sum.valPx} color={inkC} bg={paper} x={CL} base={L.sum.rows[q].valBase} track={-0.02} />
<div style={{position: 'absolute', left: CL, top: L.sum.rows[q].valBase + 26, width: CW, height: 2, backgroundColor: inkC, opacity: 0.5}} />
<Check x={CR - 64} y={L.sum.rows[q].valBase - 66} s={64} t={ramp(f, IN_F + 14 + q * 11, IN_F + 32 + q * 11, Easing.linear)} color={accentText} />
</div>); })}
{p.cuts[ci].line && <div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: ramp(f, IN_F + 8 + L.items.length * 6, IN_F + 20 + L.items.length * 6), transform: `translateY(${(1 - ramp(f, IN_F + 8 + L.items.length * 6, IN_F + 22 + L.items.length * 6)) * 24}px)`}}>
<T k="line" g={ci} text={p.cuts[ci].line!} font={NSK} weight={500} size={L.sum.linePx} color={inkC} bg={paper} x={CL} base={L.sum.lineBase} />
</div>}
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: ramp(f, IN_F + 16 + L.items.length * 6, IN_F + 22 + L.items.length * 6), transformOrigin: `${CL}px ${L.sum.brandBase}px`, transform: `scale(${lerp(1.25, 1, ramp(f, IN_F + 16 + L.items.length * 6, IN_F + 24 + L.items.length * 6, BACK)) * (1 + 0.05 * clamp01((f - IN_F - 24 - L.items.length * 6) / 40))})`}}>
<div style={{position: 'absolute', left: CL, top: L.sum.brandBase - L.sum.brandPx * 0.86 - 34, width: 120, height: 8, backgroundColor: accent}} />
<T k="brandBig" g={ci} inked text={p.brand} font={NSK} weight={900} size={L.sum.brandPx} color={inkC} bg={paper} x={CL} base={L.sum.brandBase} track={-0.02} />
</div>
</>}
</div>
);
};
return (
<AbsoluteFill>
<Paper color={paper} mottle={mottle} fiber={fiber} vignette={0.05} />
{/* 장부 괘선 + 여백선(컷마다 위로 흐름) */}
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: H, transform: `translateY(${((frame * -0.9) % 96)}px)`}}>
{ruleLines.map((q) => <div key={q} style={{position: 'absolute', left: 0, top: 1296 + q * 96 - 96 * 6, width: W, height: 2, backgroundColor: inkC, opacity: q < 6 ? 0 : 0.09}} />)}
</div>
{/* 바닥 칸: 큰 숫자 윤곽선(포인트색, 오른쪽 아래로 잘림) — 장부보다 빠르게 오른다 */}
{(() => { const wm = cover ? L.date : last ? '✓' : String(i).padStart(2, '0'); const wpx = cover ? 560 : 900;
return <div style={{position: 'absolute', right: -60, top: 1300 + (1 - u) * (last ? 220 : 60), fontFamily: last ? NSK : BOD, fontWeight: last ? 800 : 400, fontSize: wpx, lineHeight: 1, letterSpacing: '0.01em', color: 'transparent', WebkitTextStroke: `3px ${accent}`, pointerEvents: 'auto', opacity: 0.5 * (cover ? ramp(f, 6, 20) : ramp(f, 0, 10)), whiteSpace: 'pre'}} data-gfx="outline-number">{wm}</div>; })()}
<div style={{position: 'absolute', left: MARGIN_X, top: 0, width: 3, height: H * (cover ? ramp(f, 0, 18) : 1), backgroundColor: accent, opacity: 0.55}} />
{/* 머리줄 */}
<T k="brand" text={p.brand} font={NSK} weight={700} size={40} color={inkC} bg={paper} x={CL} base={300} />
<T k="hdate" text={L.date} font={BOD} weight={400} size={42} color={inkC} bg={paper} x={CR} base={300} align="right" style={{opacity: cover ? ramp(f, 30, 40) : 1}} />
<div style={{position: 'absolute', left: CL, top: 330, width: CW * (cover ? ramp(f, 0, 14) : 1), height: 3, backgroundColor: inkC}} />
{/* 앞 내용은 괘선을 따라 위로 밀려 나가며 7프레임 안에 사라지고, 그다음 새 내용이 아래에서 올라온다(겹침 없음) */}
{i > 0 && f < IN_F && content(i - 1, tl[i - 1].len, -22 - 220 * ramp(f, 0, IN_F, IN), 1 - ramp(f, 1, IN_F - 1))}
{content(i, f, drift + enterY, enterA)}
</AbsoluteFill>
);
};
const cnDefaults: CnProps = {
brand: '도토리 빵집', colors: {paper: '#F3ECE0', ink: '#231F1B', accent: '#B4532A'},
cuts: [
{sec: 2.6, big: '11.01', line: '이렇게 달라져요'},
{sec: 2.2, item: {label: '영업시간', before: '오전 8시 – 오후 8시', after: '오전 9시 – 오후 9시'}},
{sec: 2.2, item: {label: '쉬는 날', before: '월요일', after: '화요일'}},
{sec: 2.2, item: {label: '우유 식빵', before: '4,500원', after: '4,800원'}},
{sec: 2.6, line: '늘 같은 자리에서 굽습니다'},
],
};
registerVideo<CnProps, CnLayout>({
slug: 'change-notice-video', minCuts: 3, maxCuts: 5, minSec: 8, maxSec: 12, cutMin: 1.8, cutMax: 4,
fonts: (p) => ({css: CN_FONTS, specs: [[`400 100px ${BOD}`, (p.cuts[0].big ?? '') + '0123456789'], [`700 100px ${NSK}`, p.brand + p.cuts.map((c) => (c.line ?? '') + (c.item ? c.item.label + c.item.before + c.item.after : '')).join('') + '전후'], [`500 100px ${NSK}`, p.cuts.map((c) => (c.line ?? '') + (c.item ? c.item.label + c.item.before : '')).join('')], [`900 100px ${NSK}`, p.brand]]}),
setup: cnSetup,
paper: (p) => p.colors.paper,
texture: (p) => p.texture,
// 읽을 글자: 표지 = 제목, 항목 = 이름 + 후 값 + 전 값의 절반(지워지는 값), 마지막 = 한 줄 + 후 값들의 절반(앞에서 본 값)
readChars: (p, i) => { const c = p.cuts[i], len = (s: string) => [...s].filter((ch) => ch.trim()).length;
if (i === 0) return len(c.line ?? '');
if (c.item) return len(c.item.label) + len(c.item.after) + 0.5 * len(c.item.before);
return len(c.line ?? '') + 0.5 * len(p.cuts.slice(1, -1).map((x) => x.item?.after ?? '').join('')); },
Body: CnBody as any,
extraCheck: (p, L) => {
const over: string[] = [], hint: string[] = [];
const mid = p.cuts.slice(1, -1);
if (mid.some((c) => !c.item || !c.item.label || !c.item.before || !c.item.after)) { over.push('item-missing'); hint.push('가운데 컷마다 item {label, before, after} 가 모두 있어야 한다'); }
if (L.cover.px < 200) { over.push('date-long'); hint.push('표지 날짜가 길다 — 11.01 처럼 짧게'); }
L.pages.forEach((pg, k) => { if (pg.afterPx < 60 || pg.labelPx < 52) { over.push('item-long'); hint.push(`항목 ${k + 1} 글자가 길다 — 이름 6자·값 12자 안팎으로`); } });
return {overflow: over, hint, info: {mark: L.markColor, cover: L.cover, pages: L.pages.map((x) => ({labelPx: Math.round(x.labelPx), beforePx: Math.round(x.beforePx), afterPx: Math.round(x.afterPx)}))}};
},
}, cnDefaults);
```
`src/duchamp-frame.tsx`
```tsx
// ── Duchamp 프레임 모듈 · 컴포지션이 쓰는 최소 API(프레임·설정·보간·이징·이미지·렌더 대기)를 HyperFrames 위에서 돌린다 ──
// render.mjs 가 이 파일과 src/index.tsx 를 esbuild 로 묶어 HyperFrames 프로젝트(.render/)를 만들고 hyperframes CLI 로 렌더한다.
// 프레임 = HyperFrames 의 hf-seek 이벤트 시각 × fps. 그 프레임으로 React 를 동기 갱신하고(flushSync), 대기(delayRender)·이미지 로드가 끝나야 캡처된다.
import React, {createContext, useContext, useState} from 'react';
import {flushSync} from 'react-dom';
import {createRoot} from 'react-dom/client';
type AnyProps = Record<string, unknown>;
type Meta = {durationInFrames?: number; width?: number; height?: number; fps?: number; props?: AnyProps};
type CompositionProps = {
id: string; component: React.ComponentType<any>; width: number; height: number; fps: number; durationInFrames: number;
defaultProps?: AnyProps; calculateMetadata?: (o: {props: AnyProps; defaultProps: AnyProps; compositionId: string}) => Meta | Promise<Meta>;
};
// render.mjs 가 index.html 에 넣는 값: props(props.json + 장 번호 등), frames(정지 이미지면 그릴 프레임 목록), videos(영상 판 프레임 폴더)
type Face = {w: [number, number]; variable: boolean; range: string | null; url: string};
type Job = {props: AnyProps; frames: number[] | null; videos: Record<string, {dir: string; first?: number; count: number}>; fonts?: Record<string, {faces: Face[]; url: string | null}>};
declare global {
interface Window { __DUCHAMP?: Job; __duchampReady?: () => void; __duchampFailed?: string }
// eslint-disable-next-line no-var
var __DUCHAMP_INPUT: AnyProps | undefined;
// eslint-disable-next-line no-var
var __DUCHAMP_META: Promise<{id: string; width: number; height: number; fps: number; durationInFrames: number}> | undefined;
}
// ── 폰트: 컴포지션이 붙이는 Google Fonts·jsDelivr <link> 를 이 폴더에 설치된 폰트 패키지(fonts.json)로 바꾼다 — 네트워크 없이 렌더 ──
// 주소의 family·축 값(wght@500;700 · opsz,wght@36,900 · opsz,wght@6..96,400..900)을 읽어 같은 @font-face 를 만든다.
// 가변 폰트로 정적 굵기를 부르면 그 굵기(와 opsz)에 고정한 면을 만든다(Google 이 주는 정적 인스턴스와 같게). 설치 안 된 폰트 = 렌더 실패
const FONT_LINK = /^https:\/\/(fonts\.googleapis\.com\/css2?\?|cdn\.jsdelivr\.net\/gh\/orioncactus\/pretendard)/;
const fontCss = (href: string): string => {
const list = (typeof window !== 'undefined' && window.__DUCHAMP?.fonts) || {};
const want: {name: string; axes: string[]; tuples: string[][]}[] = [];
if (href.includes('fonts.googleapis.com')) {
for (const fam of new URL(href).searchParams.getAll('family')) {
const [name, spec] = fam.split(':');
const [axes, vals] = spec ? spec.split('@') : ['wght', '400'];
want.push({name, axes: axes.split(','), tuples: vals.split(';').map((t) => t.split(','))});
}
} else {
const name = Object.keys(list).find((k) => list[k].url && href.startsWith(list[k].url!.replace(/\.min\.css$/, ''))) ?? 'Pretendard Variable';
want.push({name, axes: [], tuples: [[]]});
}
const abs = (u: string) => new URL(u, location.href).href;
const rules: string[] = [];
for (const {name, axes, tuples} of want) {
const f = list[name];
if (!f || !f.faces.length) throw new Error(`폰트 패키지 없음: ${name} — package.json 의 폰트 패키지를 npm install 했는지, fonts.json 에 있는지 확인`);
for (const t of tuples) {
const at = (k: string) => { const i = axes.indexOf(k); return i < 0 ? null : t[i]; };
const wv = at('wght') ?? (axes.length ? '400' : null), ov = at('opsz');
const [w0, w1] = wv ? wv.split('..').map(Number) : [NaN, NaN];
const pinW = wv !== null && w1 === undefined, pinO = ov !== null && !ov.includes('..');
const faces = f.faces.filter((x) => wv === null || (x.variable ? x.w[0] <= w0 && w0 <= x.w[1] : x.w[0] === w0 || (w1 !== undefined && w0 <= x.w[0] && x.w[0] <= w1)));
if (!faces.length) throw new Error(`폰트 굵기 없음: ${name} ${wv}`);
for (const x of faces) {
const weight = x.variable ? (pinW ? `${w0}` : wv ? `${w0} ${w1}` : `${x.w[0]} ${x.w[1]}`) : `${x.w[0]}`;
const vs = x.variable ? [pinW ? `'wght' ${w0}` : '', pinO ? `'opsz' ${ov}` : ''].filter(Boolean).join(', ') : '';
rules.push(`@font-face{font-family:'${name}';font-style:normal;font-display:block;font-weight:${weight};src:url(${abs(x.url)}) format('woff2');${x.range ? `unicode-range:${x.range};` : ''}${vs ? `font-variation-settings:${vs};` : ''}}`);
}
}
}
return rules.join('\n');
};
if (typeof document !== 'undefined') {
const swap = (node: Node) => {
if (node instanceof HTMLLinkElement && FONT_LINK.test(node.href)) {
try { node.href = 'data:text/css;charset=utf-8,' + encodeURIComponent(fontCss(node.href)); } catch (e) { cancelRender(e); }
}
};
const append = Node.prototype.appendChild, before = Node.prototype.insertBefore;
Node.prototype.appendChild = function <T extends Node>(this: Node, node: T): T { swap(node); return append.call(this, node) as T; };
Node.prototype.insertBefore = function <T extends Node>(this: Node, node: T, ref: Node | null): T { swap(node); return before.call(this, node, ref) as T; };
}
// ── 프레임 ──
const FrameContext = createContext(0);
export const useCurrentFrame = () => useContext(FrameContext);
type VideoConfig = {id: string; width: number; height: number; fps: number; durationInFrames: number; defaultProps: AnyProps; props: AnyProps};
const ConfigContext = createContext<VideoConfig | null>(null);
export const useVideoConfig = (): VideoConfig => { const c = useContext(ConfigContext); if (!c) throw new Error('useVideoConfig: 컴포지션 밖에서 불림'); return c; };
// ── 렌더 대기: delayRender 로 잡은 손잡이가 모두 풀려야 그 프레임을 캡처한다 ──
const pending = new Map<number, ReturnType<typeof setTimeout>>();
let nextHandle = 0;
let idleWaiters: (() => void)[] = [];
const idle = () => (pending.size === 0 || failed() ? Promise.resolve() : new Promise<void>((res) => idleWaiters.push(res)));
const wakeIdle = () => { const w = idleWaiters; idleWaiters = []; w.forEach((r) => r()); };
const failed = () => typeof window !== 'undefined' && Boolean(window.__duchampFailed);
// 실패: 로그에 DUCHAMP_FAIL 을 남기고(render.mjs 가 보고 멈춘다) 대기를 모두 푼다
export const cancelRender = (err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err);
if (typeof window === 'undefined') throw err instanceof Error ? err : new Error(msg);
if (!window.__duchampFailed) { window.__duchampFailed = msg; console.error('DUCHAMP_FAIL ' + msg); }
pending.forEach((t) => clearTimeout(t)); pending.clear();
window.__duchampReady?.();
wakeIdle();
};
// 기본 한도 90초(폰트·판 분석이 느린 환경 대비). 시간 초과면 폰트 상태를 같이 남긴다 → render.mjs 가 한 번 더 시도한다
export const delayRender = (label?: string, opts?: {timeoutInMilliseconds?: number}): number => {
const h = ++nextHandle, ms = Math.max(opts?.timeoutInMilliseconds ?? 0, 90000);
pending.set(h, setTimeout(() => cancelRender(new Error(`delayRender 시간 초과(${ms}ms): ${label ?? h} · fonts ${document.fonts.status}`)), ms));
return h;
};
export const continueRender = (h: number): void => {
const t = pending.get(h);
if (t === undefined) return;
clearTimeout(t); pending.delete(h);
if (pending.size === 0) wakeIdle();
};
// ── 보간·이징 ──
type Extrapolate = 'extend' | 'clamp' | 'identity';
type InterpolateOptions = {easing?: (t: number) => number; extrapolateLeft?: Extrapolate; extrapolateRight?: Extrapolate};
const segment = (input: number, range: readonly number[]) => {
let i = 1;
for (; i < range.length - 1; i++) if (range[i] >= input) break;
return i - 1;
};
export const interpolate = (input: number, inputRange: readonly number[], outputRange: readonly number[], options: InterpolateOptions = {}): number => {
if (inputRange.length !== outputRange.length) throw new Error('interpolate: inputRange 와 outputRange 길이가 다름');
if (inputRange.length < 2) throw new Error('interpolate: 범위는 2개 이상');
for (let k = 1; k < inputRange.length; k++) if (!(inputRange[k] > inputRange[k - 1])) throw new Error('interpolate: inputRange 는 계속 커져야 함');
const i = segment(input, inputRange);
const [a, b] = [inputRange[i], inputRange[i + 1]], [c, d] = [outputRange[i], outputRange[i + 1]];
const left = options.extrapolateLeft ?? 'extend', right = options.extrapolateRight ?? 'extend', ease = options.easing ?? ((t: number) => t);
let x = input;
if (x < a) { if (left === 'identity') return x; if (left === 'clamp') x = a; }
if (x > b) { if (right === 'identity') return x; if (right === 'clamp') x = b; }
if (c === d) return c;
return ease((x - a) / (b - a)) * (d - c) + c;
};
// cubic-bezier(x1, y1, x2, y2): x 에서 t 를 뉴턴법(기울기가 작으면 이분법)으로 풀고 y(t) 를 돌려준다. 표본 11개로 시작점을 잡는다
const bezier = (x1: number, y1: number, x2: number, y2: number) => {
if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) throw new Error('bezier: x 값은 0~1');
const A = (p: number, q: number) => 1 - 3 * q + 3 * p, B = (p: number, q: number) => 3 * q - 6 * p, C = (p: number) => 3 * p;
const at = (t: number, p: number, q: number) => ((A(p, q) * t + B(p, q)) * t + C(p)) * t;
const slope = (t: number, p: number, q: number) => 3 * A(p, q) * t * t + 2 * B(p, q) * t + C(p);
if (x1 === y1 && x2 === y2) return (t: number) => t;
const N = 11, step = 1 / (N - 1), samples = Array.from({length: N}, (_, k) => at(k * step, x1, x2));
const tForX = (x: number) => {
let start = 0, k = 1;
for (; k !== N - 1 && samples[k] <= x; k++) start += step;
k--;
const guess = start + ((x - samples[k]) / (samples[k + 1] - samples[k])) * step, s = slope(guess, x1, x2);
if (s >= 0.001) {
let t = guess;
for (let n = 0; n < 4; n++) { const sl = slope(t, x1, x2); if (sl === 0) break; t -= (at(t, x1, x2) - x) / sl; }
return t;
}
if (s === 0) return guess;
let lo = start, hi = start + step, t = 0, e = 0, n = 0;
do { t = lo + (hi - lo) / 2; e = at(t, x1, x2) - x; if (e > 0) hi = t; else lo = t; } while (Math.abs(e) > 1e-7 && ++n < 10);
return t;
};
return (x: number) => (x === 0 || x === 1 ? x : at(tForX(x), y1, y2));
};
export const Easing = {
linear: (t: number) => t,
ease: (t: number) => bezier(0.42, 0, 1, 1)(t),
quad: (t: number) => t * t,
cubic: (t: number) => t * t * t,
poly: (n: number) => (t: number) => Math.pow(t, n),
sin: (t: number) => 1 - Math.cos((t * Math.PI) / 2),
circle: (t: number) => 1 - Math.sqrt(1 - t * t),
exp: (t: number) => Math.pow(2, 10 * (t - 1)),
bezier,
in: (e: (t: number) => number) => e,
out: (e: (t: number) => number) => (t: number) => 1 - e(1 - t),
inOut: (e: (t: number) => number) => (t: number) => (t < 0.5 ? e(t * 2) / 2 : 1 - e((1 - t) * 2) / 2),
};
// ── 화면 요소 ──
export const AbsoluteFill = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({style, ...rest}, ref) => (
<div ref={ref} style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%', display: 'flex', flexDirection: 'column', ...style}} {...rest} />
));
// public/ 안 파일 주소(.render/public → ../public)
export const staticFile = (p: string) => 'public/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
// 이미지: 로드 실패 = 렌더 실패. 로드·디코드가 끝날 때까지 캡처를 기다린다(아래 imagesReady)
export const Img = React.forwardRef<HTMLImageElement, React.ImgHTMLAttributes<HTMLImageElement>>(({onError, ...rest}, ref) => (
<img ref={ref} decoding="sync" {...rest} onError={(e) => { onError?.(e); cancelRender(new Error('이미지 로드 실패: ' + rest.src)); }} />
));
// 영상 판: render.mjs 가 props 안의 영상 파일을 fps 에 맞춰 프레임 JPEG 로 미리 뽑아 둔다 → 프레임마다 그 장을 <img> 로 보인다
// 영상 프레임 = 컴포지션 프레임 × playbackRate + startFrom (0 ~ 끝 장으로 자름). 컷 안에서 쓰면 startFrom 에 컷 시작을 빼 준다(릴스: clipStart×fps − 컷 시작)
export const OffthreadVideo = ({src, startFrom = 0, playbackRate = 1, muted: _muted, ...rest}: React.ImgHTMLAttributes<HTMLImageElement> & {src: string; startFrom?: number; playbackRate?: number; muted?: boolean}) => {
const frame = useCurrentFrame();
const key = decodeURIComponent(src.replace(/^public\//, ''));
const v = typeof window !== 'undefined' ? window.__DUCHAMP?.videos?.[key] : undefined;
if (!v) { cancelRender(new Error('영상 프레임이 없음(props.json 에 적힌 public/ 안 영상만 쓸 수 있다): ' + key)); return null; }
// 뽑아 둔 구간(first 부터 count 장) 안으로 자른다
const n = Math.min(v.count - 1, Math.max(0, Math.round(frame * playbackRate + startFrom) - (v.first ?? 0)));
return <Img {...rest} src={`${v.dir}/${String(n + 1).padStart(6, '0')}.jpg`} />;
};
// ── 등록 ──
export const Composition = (_: CompositionProps) => null;
const findComposition = (el: React.ReactNode): CompositionProps => {
const list = React.Children.toArray(el) as React.ReactElement<any>[];
for (const c of list) {
if (c.type === Composition) return c.props as CompositionProps;
if (c.props?.children) { try { return findComposition(c.props.children); } catch { /* 다음 */ } }
}
throw new Error('registerRoot: <Composition> 을 찾지 못함');
};
const resolveMeta = async (c: CompositionProps, input: AnyProps) => {
const defaultProps = c.defaultProps ?? {};
const props = {...defaultProps, ...input};
const m = c.calculateMetadata ? await c.calculateMetadata({props, defaultProps, compositionId: c.id}) : {};
return {id: c.id, width: m.width ?? c.width, height: m.height ?? c.height, fps: m.fps ?? c.fps, durationInFrames: m.durationInFrames ?? c.durationInFrames, props: m.props ?? props};
};
// 캡처 전에 화면 안 이미지가 모두 로드·디코드됐는지
const imagesReady = async (root: Element) => {
await Promise.all([...root.querySelectorAll('img')].map((img) => (img.complete && img.naturalWidth > 0 ? null
: new Promise<void>((res) => { img.addEventListener('load', () => res(), {once: true}); img.addEventListener('error', () => res(), {once: true}); if (img.complete) res(); })
.then(() => img.decode().catch(() => undefined)))));
};
export const registerRoot = (fn: () => React.ReactNode) => {
const comp = findComposition(fn());
// Node(render.mjs 가 크기·길이를 미리 읽을 때): 메타만 계산
if (typeof document === 'undefined') { globalThis.__DUCHAMP_META = resolveMeta(comp, globalThis.__DUCHAMP_INPUT ?? {}); return; }
const job = window.__DUCHAMP ?? {props: {}, frames: null, videos: {}};
void resolveMeta(comp, job.props).then((meta) => {
const frameAt = (t: number) => { const k = Math.round(t * meta.fps); return job.frames ? job.frames[Math.min(job.frames.length - 1, Math.max(0, k))] : k; };
let setFrame: (f: number) => void = () => undefined;
const config: VideoConfig = {id: meta.id, width: meta.width, height: meta.height, fps: meta.fps, durationInFrames: meta.durationInFrames, defaultProps: comp.defaultProps ?? {}, props: meta.props};
const Root = () => {
const [f, set] = useState(() => frameAt(0));
setFrame = set;
return <ConfigContext.Provider value={config}><FrameContext.Provider value={f}>{React.createElement(comp.component, meta.props)}</FrameContext.Provider></ConfigContext.Provider>;
};
// HyperFrames 는 렌더 중 requestAnimationFrame 을 가상 시각(seek)에 묶어 둔다 → 첫 화면 준비(폰트·측정) 동안만 실제 프레임(+50ms 타이머 예비)으로 돌려준다
const virtualRaf = window.requestAnimationFrame;
const nativeRaf = (window as any).__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame as typeof requestAnimationFrame | undefined;
if (nativeRaf) window.requestAnimationFrame = (cb) => { let done = false; const run = () => { if (!done) { done = true; cb(performance.now()); } }; nativeRaf(run); setTimeout(run, 50); return 0; };
const ready = () => { window.requestAnimationFrame = virtualRaf; window.__duchampReady?.(); };
const host = document.querySelector('[data-composition-id]')!;
const portal = document.createElement('div');
portal.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;display:flex;flex-direction:column';
host.appendChild(portal);
const root = createRoot(portal);
flushSync(() => root.render(<Root />));
// 첫 화면 준비(폰트·판 분석·자동 맞춤 = 컴포지션의 delayRender) → HyperFrames 에 준비 완료를 알린다
void idle().then(() => imagesReady(portal)).then(ready);
window.addEventListener('hf-seek', (e) => {
const d = (e as CustomEvent<{time: number; waitUntil: (p: Promise<unknown>) => void}>).detail;
d.waitUntil((async () => {
await idle();
flushSync(() => setFrame(frameAt(d.time)));
await idle();
await imagesReady(portal);
})());
});
}).catch((e) => cancelRender(e));
};
```
## 7. 실행
```bash
npm install --no-audit --no-fund
node render.mjs stills # 먼저: 컷 화면 PNG → out/cut<n>-a.png(컷 시작 0.4초) · out/cut<n>-b.png(컷 60%, 대표) · out/hook0.png(0초)·hook.png(0.3초) + out/check.json (약 10초)
node render.mjs mp4 # 통과하면: 같은 검사 + out/video.mp4(무음, 1080×1920, 30fps) + 전환 겹침 검사(모든 프레임) + 움직임 검사 + out/frames/c<n>-a|b|c.png
```
- 한 번 실행에 모든 컷을 같은 코드·같은 폰트로 렌더한다. 폰트를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
- **종료 코드 2 = 검사 실패 → 거기서 멈춘다.** `out/check.json` 의 `overflow`·`hint` 를 읽고 원인(글자 길이·컷 길이·브랜드 색·사진)을 고친 뒤 다시 실행한다. 글자를 줄이거나 바꿔야 하면 마음대로 고치지 말고 사용자에게 고친 안을 물어본다.
- 렌더 시간(참고, Apple Silicon): stills 약 10초, mp4 10~12초 영상에 약 20~30초.
## 8. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: 맨 위 `ok: true`, `overflow: []`. `overflow` 뜻: `item-missing`(항목 컷에 이름·전·후 중 빈 칸), `item-long`·`date-long`(글자가 길다 → 줄인 안을 묻는다), `safe-area`, `contrast`(브랜드 색 조합 대비 부족 → 색을 바꿀지 사용자에게), `yellow-text`, `text-collide`(코드 문제), `read-time`(글자에 비해 컷이 짧다 → 컷을 늘리거나 글자를 줄인다), `cut-count`·`cut-length`, `hook`·`hook-static`(첫 0.3초에 주인공 글자가 들어오지 않거나 0프레임이 이미 완성된 정지 화면 — 코드 문제)·`gfx-collide`(그래픽이 글자에 걸림 — 코드 문제, `gfx.sample` 에 프레임·글자)·`text-overlap`(전환 중 글자 겹침 프레임 — 코드 문제, `overlap.sample` 에 프레임·글자)·`line-break`(코드가 나눈 줄이 수+단위·관형사+명사 사이에서 바뀜 — 코드 문제. 사용자가 ` | ` 로 나눈 줄은 검사하지 않는다)·`cut-similar`·`motion-still`(0.8초 넘게 거의 멈춤)·`audio`(코드 문제). `cuts[0].info` 에 형광 띠 색(`mark`)·표지 배치·항목별 글자 크기, `cuts[].items` 에 글자마다 잉크 상자·대비, `similar`·`motion`·`mp4` 가 남는다.
2. `out/cut1-b.png` ~ 마지막 컷 `-b.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구): 글자가 입력과 글자 하나까지 같은지(시간·요일·가격·쉼표·대시 포함), 항목 컷마다 "전" 값에 줄이 그어지고 "후" 값이 형광 띠 위에 또렷한지, 번호가 항목 순서와 맞는지, 모아 보기에 후 값만 모였는지, 마지막에 브랜드명이 크게 있는지.
3. MP4 이면 `out/hook0.png`(0초 — 날짜가 찍히는 중)·`out/hook.png`(0.3초 — 날짜가 다 보임)와 `out/frames/c<n>-a|b|c.png` 를 본다: `c2-a`~(컷 시작 0.13초)에 앞 내용이 위로 빠지는 중이고 새 내용과 겹치지 않는지, 번호의 `1` 이 `I` 로 보이지 않는지. 영상은 props 합계 초, 1080×1920, 30fps, 소리 없음.
4. 문제가 있으면 원인(글자 길이·props)을 고쳐 다시 렌더한다.
## 9. 보고
결과 파일 경로(`out/video.mp4`, `out/cut<n>-b.png`), 질감 판을 썼는지(어떻게 얻었는지), check.json 의 항목별 글자 크기·형광 띠 색·최소 대비·`similar.max`·`motion`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 그리고 한 줄: **"이 영상은 무음입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 저작권 걱정 없는 음원으로 붙이세요."**
폰트(모두 SIL OFL 1.1, 상업 사용 가능): 날짜·번호 Abril Fatface · 한글 Noto Serif KR.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Codex(권장)나 Claude Code 에 붙여 넣고 브랜드·색·컷별 글자를 알려 주세요. 이미지 생성 없이 코드만으로 렌더해서 어느 쪽이든 그대로 됩니다.
- 올리기
MP4 파일을 받아 릴스·쇼츠로 올리세요. 음악은 편집 앱에서 붙이세요.
컷 구성
- 컷 1 · 2.6초 · 표지
- 바뀌는 날짜가 찍히듯 내려앉고, 제목과 바뀌는 항목 목록이 한 줄씩 적힌다
- 컷 2 · 2.2초 · 항목 1
- 큰 번호와 항목 이름 — 전 값에 펜 줄이 그어지고, 후 값이 올라와 형광 띠 위에 앉는다
- 컷 3 · 2.2초 · 항목 2
- 두 번째 바뀌는 것 — 번호와 이름 자리가 좌우로 바뀌고, 짧은 후 값은 폭을 채우게 크게
- 컷 4 · 2.2초 · 항목 3
- 세 번째 바뀌는 것 — 항목은 1~3개, 그 수만큼 컷이 생긴다
- 컷 5 · 2.6초 · 모아 보기
- 바뀐 값만 모은 목록에 체크가 그려지고, 한 줄과 큰 브랜드명이 찍힌다
예시 영상의 컷입니다. 레시피는 컷 3~5개 · 합계 8~12초 안에서 내가 준 항목 수에 맞춰 컷을 나눕니다.
지킬 것
- 잘 맞는 때: 영업시간·휴무·가격·운영 방식이 바뀌는 날을 미리 알릴 때
- 시간·요일·가격은 내가 알려 준 값만 씁니다 — 이유·기간·사과 문구를 지어 넣지 않게 돼 있어요.
- 항목 이름은 6자, 값은 12자 안팎이 넉넉합니다. 길면 코드 검사가 멈추고 줄인 안을 물어봐요.
- 무음 MP4 입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 붙이세요.


