← 영상
영상
오픈 카운트다운
코드 렌더 · 같은 결과
일력을 하루씩 찢어 넘기며 D-3부터 오픈 당일까지 세어 내려갑니다.
가상 브랜드 모서리 책방로 레시피를 실제 실행해 렌더한 영상입니다. 소리는 없습니다.
레시피 보기102,856자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 영상 「오픈 카운트다운」 9:16 · 컷 4개 · 약 11초 (D-3 → D-2 → D-1 → 오늘 오픈)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. **AI 영상 생성을 쓰지 않는다.** 판도 필요 없다 — 글자·면·움직임을 코드(React + HyperFrames)가 실제 폰트로 그려 세로 MP4 한 편으로 렌더한다.
- 9:16 1080×1920 · 30fps · 컷 4개(3~5개, 합계 8~12초, 컷마다 1.8~4초) · **무음**(음악은 편집 앱에서)
- **화면 = 화면을 꽉 채운 일력 한 장(위 제본 띠만 남김), 컷 = 하루.** 컷이 바뀔 때 앞 장 아래 끝이 말려 올라가며(가로 띠 10개가 아래로 갈수록 더 꺾임 — 종이가 휜다, 꺾인 곳은 그늘·90° 넘으면 종이 뒷면) 위로 찢겨 나가고(찢긴 자국이 제본 아래 쌓인다) 다음 장이 드러난다. 장마다 면 색이 바뀐다(종이 → 포인트색 → 먹색 → … 마지막은 종이, 포인트색이 작은 글자 대비 4.5 를 못 내면 종이·먹색만 번갈아).
- 장마다: 위 작은 브랜드명 · 남은 날 점(●○○○) · 컷 시간만큼 차오르는 진행선 · **`D-3` 한 덩어리 초대형 글자**(Anton, 폭을 꽉 채움) · 한 줄(Pretendard 800, 의미 단위로 1~2줄) · 아래 35% 에 **떠오르는 해**(포인트색 원 — 날이 갈수록 높이, 컷 안에서도 계속 오른다, 가장자리는 인쇄처럼 살짝 거칠게). **배치는 컷마다 번갈아 바뀐다**: 숫자 위·한 줄 아래 ↔ 한 줄 위·숫자 아래(한 줄은 언제나 왼쪽 맞춤).
- 마지막 장: **큰 말 두 줄**(Black Han Sans — 속공간이 열린 극태. 획 덧입히기·거칠기 필터는 쓰지 않는다. 예: 오늘 / 오픈. 윗줄 왼쪽·아랫줄 오른쪽 맞춤으로 엇갈려 폭을 채우고, 아랫줄 포인트색) + 날짜·장소 두 줄 + 다 떠오른 해와 햇살.
- 움직임: 0프레임은 빈 장 → `D-3` 이 1.5배에서 0.23초 만에 **찍히듯** 내려앉고(살짝 넘침) 한 줄이 한 줄씩 올라온다. 다음 장들도 앞 장이 다 걷힌 뒤(0.4초) 숫자가 찍힌다(찢겨 나가는 장의 글자와 겹치지 않는다). 컷 안에서는 해가 떠오르고, 숫자가 천천히 다가오며 옆으로 흐르고, 진행선이 찬다. 마지막 장은 큰 말이 한 줄씩 찍히고 햇살이 뻗으며 천천히 돈다. 종이 장에는 고정된 종이 결(이·섬유, 영역마다 세기가 다름)이 있고 움직이는 빛·흐린 얼룩은 없다.
- 코드 검사: 글자 넘침·안전 영역(위 12%·아래 35%·좌우 64px)·대비(작은 글자 4.5, 큰 글자 3)·노랑 글자·글자끼리 겹침·컷 길이·읽는 시간·**0.3초 훅**(첫 프레임은 완성된 정지 화면이 아니라 찍히듯·들어오듯 등장 중 — 0초와 0.3초 화면 차이를 잰다)·컷끼리 닮음·**전환 겹침**(컷이 다른 글자 둘이 같은 자리에 동시에 보이면 실패 — 나가는 글자가 다 빠진 뒤 들어온다, MP4 모든 프레임에서 잰다)·**자모 속공간**(큰 말의 ㄴ·ㄹ·ㅂ·ㅇ·ㅍ 속이 실틈으로 막히면 실패)·**그래픽↔글자 겹침**(해·햇살·원·고리·시계·선·윤곽 숫자가 글자 잉크+12px 에 걸리면 실패 — 모든 프레임에서 실제 모양으로 잰다)·**의미 단위 줄바꿈**(수+단위·관형사+명사 사이에서 줄을 바꾸지 않는다)·**컷 안 멈춤**(MP4 를 다시 풀어 0.5초 간격 프레임 차이 — 0.8초 넘게 거의 멈추면 실패)·무음.
원본 템플릿: https://duchamp.app/readymade/videos/open-countdown-video
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "밝은 색(종이), 어두운 색(먹), 포인트색" HEX 3개. 비우면 #EFE8DA, #1F1C19, #C2412D. 포인트색이 노랑이면 해·면으로만 쓰고 글자에는 안 쓴다
주제: (내 브랜드에 맞는 주제 하나) # 무엇을 여는지. 예: 을지로에 작은 바 오픈
넣을 글자: (주제에 맞춰 분량 상한 안에서 컷별 글자를 지어 넣는다) # 컷마다. "큰:" = 큰 글자, "줄:" = 작은 줄
# 컷 1: 큰: D-3 / 줄: 을지로에 작은 바를 엽니다 (줄 18자 안팎)
# 컷 2: 큰: D-2 / 줄: 잔은 열두 개만 놓았어요
# 컷 3: 큰: D-1 / 줄: 내일 저녁 여섯 시
# 컷 4: 큰: 오늘 | 오픈 / 줄: 10월 24일 토요일 저녁 6시 / 줄: 서울 중구 을지로 15길 3
# (큰 글자는 D-숫자. 마지막 컷 큰 말은 2~6자 두 줄까지 " | ", 줄은 두 개까지 — 날짜·장소 같은 사실만)
# (컷 수: D-2 부터면 3컷, D-4 부터면 5컷)
출력: MP4 # MP4 | 컷 정지 화면만
질감 판: 없음 # 없음 | 자동 | 내 사진: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 브랜드명·글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 날짜·시간·장소·메뉴 같은 사실은 사용자가 준 것만 쓴다 — 주지 않은 사실을 지어 넣지 않는다(줄이 없으면 빈 채로 둔다).
- 줄이 너무 길어 코드가 실패하면(`read-time`·`safe-area`) 줄인 안을 사용자에게 물어본다.
- **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. 확인할 것 (렌더 전에)
판이 없으면 확인할 판도 없다. 브랜드 색 세 개가 서로 구분되는지(종이와 먹색 대비가 4.5 이상) 한 번 보고, 포인트색이 노랑 계열이면 해·면으로만 쓰인다는 것을 보고에 쓴다.
## 5. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"colors": {"paper": "<밝은 색>", "ink": "<어두운 색>", "accent": "<포인트색>"},
"cuts": [
{"sec": 2.4, "big": "D-3", "line": "<컷 1 줄 그대로>"},
{"sec": 2.4, "big": "D-2", "line": "<컷 2 줄>"},
{"sec": 2.4, "big": "D-1", "line": "<컷 3 줄>"},
{"sec": 3.6, "big": "<마지막 큰 말 — 두 줄이면 '오늘 | 오픈'>", "lines": ["<줄 1>", "<줄 2>"]}
]
}
```
- 글자는 입력 그대로(` | ` 도 그대로). 줄이 없는 컷은 `"line": null`. 마지막 컷만 `lines`(0~2개).
- 컷 수: 3~5개, 합계 8~12초, 컷마다 1.8~4초. 마지막 컷은 3.4~4초가 좋다(정보 두 줄을 읽는 시간). 5컷이면 2.0·2.0·2.0·2.0·3.6 처럼 줄인다.
- 크기·줄바꿈은 코드가 정한다: `D-3` 은 폭을 꽉 채우고(한 줄 높이를 뺀 만큼), 한 줄은 의미 단위로 1~2줄(낱말을 쪼개지 않고, 수+단위 "삼백 권"·관형사+명사 "한 송이" 사이에서는 바꾸지 않는다 — 조사·어미 뒤에서 바꾼다), 마지막 큰 말은 두 줄로 엇갈려 칸을 채운다. 줄을 직접 정하려면 ` | `.
## 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/anton": "5.3.0",
"@fontsource/black-han-sans": "5.3.0"
},
"scripts": {
"postinstall": "node hf.mjs fonts"
}
}
```
`render.mjs`
```js
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);
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]} 파일이 없음`); }
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: 오린 사진에 보이는 제품을 한 줄로'); }
}
}
}
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); };
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); }
{ 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프레임에 이미 완성된 정지 화면(코드 문제)`); } }
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'}));
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});
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;
check.overlap = {frames: ovl.size, sample: [...ovl.entries()].slice(0, 6).map(([f, p]) => ({frame: f, pairs: p}))};
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';
const W = 1080, H = 1920, FPS = 30;
const SAFE_TOP = Math.round(H * 0.12), SAFE_BOTTOM = Math.round(H * 0.65), SIDE = 64;
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};
};
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);
};
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);
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}" 뒤에서 줄이 바뀜(수+단위·관형사+명사는 한 줄에)`);
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>
);
};
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);
}
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');
};
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();
}
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');
};
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>
);
};
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="inkWide" filterUnits="userSpaceOnUse" x={0} y={0} width={W} height={H} 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>
<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>
);
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}))`; };
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};
};
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})); };
const counterIssues = (text: string, font: (px: number) => string, px: number) => {
const q = Math.min(400, px), c = document.createElement('canvas'), out: string[] = [];
c.width = Math.ceil(q * 1.6); c.height = Math.ceil(q * 1.6);
const g = c.getContext('2d', {willReadFrequently: true})!;
for (const ch of new Set([...text].filter((x) => /[가-힣]/.test(x)))) {
g.clearRect(0, 0, c.width, c.height); g.font = font(q); g.fillStyle = '#000'; g.textBaseline = 'alphabetic'; g.fillText(ch, q * 0.2, q * 1.2);
const d = g.getImageData(0, 0, c.width, c.height).data, w = c.width, h = c.height;
const on = (x: number, y: number) => d[(y * w + x) * 4 + 3] > 127;
let x0 = w, x1 = -1, y0 = h, y1 = -1;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (on(x, y)) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
if (x1 < 0) continue;
const lim = 0.02 * q; let worst = 0;
for (const vert of [true, false]) {
const n = vert ? x1 - x0 + 1 : y1 - y0 + 1, m = vert ? y1 - y0 + 1 : x1 - x0 + 1; let run = 0, best = 0;
for (let a = 0; a < n; a++) {
let last = -1, thin = false;
for (let b = 0; b < m; b++) { const v = vert ? on(x0 + a, y0 + b) : on(x0 + b, y0 + a); if (v) { if (last >= 0 && b - last - 1 >= 1 && b - last - 1 < lim) thin = true; last = b; } }
run = thin ? run + 1 : 0; best = Math.max(best, run);
}
worst = Math.max(worst, best / n);
}
if (worst > 0.15) out.push(`"${ch}" 속공간이 실틈으로 막힘(${Math.round(worst * 100)}%)`);
}
return out;
};
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); }
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});
};
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초 안에 주인공 글자가 크게·불투명하게 들어오지 않음(코드 문제)']});
};
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}));
};
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));
}, []);
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)})} />);
}
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};
};
type CdCut = {sec: number; big: string; line?: string | null; lines?: string[]};
type CdProps = {brand: string; colors: {paper: string; ink: string; accent: string}; cuts: CdCut[]; texture?: string};
const CD_FONTS = [
'https://fonts.googleapis.com/css2?family=Anton&family=Black+Han+Sans&display=block',
'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css',
];
const PRET = '"Pretendard Variable"', GAS = '"Black Han Sans"';
const BIND_B = 150; // 제본 띠 아래 끝(장 위 끝)
const CL0 = 72, CR0 = 1008, CW0 = CR0 - CL0; // 글자 칸
const TOP0 = 372, BOT0 = 1236; // 글자 잉크가 놓이는 세로 칸(안전 영역 안)
type Face = {bg: string; big: string; text: string; sun: string; rule: string};
type CdEl = {k: string; role: 'num' | 'pre' | 'line' | 'word' | 'info'; text: string; font: string; weight: number; px: number; x: number; base: number; align: 'left' | 'right' | 'center'; track: number; color: 'big' | 'text' | 'accent'; q: number};
type CdPg = {els: CdEl[]; sunY0: number; sunY1: number; sunD: number; sunX: number; split: string[][]};
type CdLayout = {faces: Face[]; pages: CdPg[]; warn: string[]};
const splitD = (s: string) => { const m = s.trim().match(/^(D\s*[-–—]\s*)(\d{1,3})$/i); return m ? {pre: 'D-', num: m[2]} : null; };
const ANTON = (px: number) => `${px}px Anton`;
const LINE7 = (px: number) => `800 ${px}px ${PRET}`;
const fitLines = (s: string, w: number, maxPx: number, minPx: number, maxLines: number, font = LINE7, track = -0.02) => {
let best: {lines: string[]; px: number; score: number} | null = null;
for (const c of lineSplits(s, maxLines)) {
if (c.lines.length > maxLines) continue;
const px = Math.min(maxPx, ...c.lines.map((x) => fitSize(x, font, w, maxPx, track)));
const n = c.lines.map((x) => [...x].length), bal = Math.min(...n) / Math.max(...n);
const score = px - c.cost * 2 - (c.lines.length > 1 ? (1 - bal) * 14 : 0) - (c.lines.length - 1) * 2;
if (px >= minPx && (!best || score > best.score)) best = {lines: c.lines, px, score};
}
return best;
};
const cdSetup = async (p: CdProps): Promise<CdLayout> => {
const {paper, ink: inkC, accent} = p.colors;
const n = p.cuts.length, warn: string[] = [];
const accentOk = Math.max(contrast(accent, paper), contrast(accent, inkC)) >= 4.5;
const faces: Face[] = p.cuts.map((_, i) => {
const kind = i === n - 1 ? 0 : accentOk ? i % 3 : (i % 2) * 2;
if (kind === 0) { const big = contrast(paper, accent) >= 3 && !isYellow(hexRgb(accent)) ? accent : inkC; return {bg: paper, big: i === n - 1 ? inkC : big, text: inkC, sun: accent, rule: mix(paper, inkC, 0.3)}; }
if (kind === 1) { const fg = bestOn(accent, paper, inkC); return {bg: accent, big: fg, text: fg, sun: mix(accent, fg, 0.2), rule: mix(accent, fg, 0.4)}; }
return {bg: inkC, big: paper, text: paper, sun: contrast(inkC, accent) >= 1.6 ? accent : mix(inkC, paper, 0.25), rule: mix(inkC, paper, 0.3)};
});
faces.forEach((f, i) => { if (isYellow(hexRgb(f.big)) || isYellow(hexRgb(f.text))) { const alt = bestOn(f.bg, paper, inkC); f.big = alt; f.text = alt; warn.push(`yellow-swap: 컷 ${i + 1} 노랑 글자 대신 ${alt}`); } });
let ci = 0;
const pages = p.cuts.map((c, i): CdPg => {
const last = i === n - 1, els: CdEl[] = [], split: string[][] = [];
const sunD = 1500, sunX = 540;
const sunY1 = 1330 + (1 - i / Math.max(1, n - 2)) * 200, sunY0 = sunY1 + 260; // 해 윗끝(날이 갈수록 높이)
if (!last) {
const d = splitD(c.big), flip = ci % 2 === 1; ci++;
const line = (c.line ?? '').trim();
const big = d ? `${d.pre}${d.num}` : c.big;
const lf = line ? fitLines(line, CW0, 92, 52, 2) : null;
const lineH = lf ? lf.lines.length * lf.px * 1.3 : 0, gap = 70;
const pxMax = fitSize(big, ANTON, (CW0 - 48) / 1.04, 1400, -0.01, BOT0 - TOP0 - lineH - gap);
const m = ink(big, ANTON(pxMax), -0.01 * pxMax), hgt = m.asc + m.desc;
const numTop = flip ? BOT0 - hgt : TOP0;
els.push({k: 'num', role: 'num', text: big, font: 'Anton', weight: 400, px: pxMax, x: CL0 + 24 + (CW0 - 48 - (m.r - m.l)) / 2, base: numTop + m.asc, align: 'left', track: -0.01, color: 'big', q: 0});
if (lf) {
const lt = flip ? TOP0 : numTop + hgt + gap;
lf.lines.forEach((s, q) => els.push({k: `line${q}`, role: 'line', text: s, font: PRET, weight: 800, px: lf.px, x: CL0, base: lt + lf.px * 0.86 + q * lf.px * 1.3, align: 'left', track: -0.02, color: 'text', q}));
split.push(lf.lines);
}
return {els, sunY0, sunY1, sunD, sunX, split};
}
const words = splitLines(c.big, 2);
split.push(words);
const info = (c.lines ?? []).slice(0, 2).map((s) => s.trim()).filter(Boolean);
const infoPx = info.length ? Math.min(52, ...info.map((s) => fitSize(s, (q) => `800 ${q}px ${PRET}`, CW0, 52))) : 52;
const infoH = info.length ? info.length * infoPx * 1.36 + 60 : 0;
const gas = (q: number) => `400 ${q}px ${GAS}`;
const boxB = BOT0 - infoH, gap = 34, lineH = (boxB - TOP0 - gap * (words.length - 1)) / words.length;
const px = Math.min(...words.map((w) => fitSize(w, gas, CW0, 1200, -0.03, lineH)));
const asc = Math.max(...words.map((w) => ink(w, gas(px), -0.03 * px).asc)), desc = Math.max(...words.map((w) => ink(w, gas(px), -0.03 * px).desc));
const used = words.length * (asc + desc) + gap * (words.length - 1), t0 = TOP0 + Math.max(0, (boxB - TOP0 - used) / 2);
words.forEach((w, q) => els.push({k: `word${q}`, role: 'word', text: w, font: GAS, weight: 400, px, x: words.length > 1 && q === words.length - 1 ? CR0 : CL0, base: t0 + asc + q * (asc + desc + gap), align: words.length > 1 && q === words.length - 1 ? 'right' : 'left', track: -0.03, color: q === words.length - 1 && words.length > 1 ? 'accent' : 'big', q}));
info.forEach((s, q) => els.push({k: `info${q}`, role: 'info', text: s, font: PRET, weight: q === 0 ? 800 : 600, px: infoPx, x: CL0, base: BOT0 - (info.length - 1 - q) * infoPx * 1.36, align: 'left', track: -0.01, color: 'text', q}));
const lowest = Math.max(...els.map((e) => e.base + ink(e.text, `${e.weight} ${e.px}px ${e.font}`, e.track * e.px).desc));
const sunL = Math.max(1370, lowest + 40 + 124);
return {els, sunY0: sunL + 150, sunY1: sunL, sunD: 1500, sunX: 540, split};
});
return {faces, pages, warn};
};
const tornPath = (seed: number, y0: number, depth: number) => {
const r = rng(seed * 97 + 13); let d = `M 0 ${y0} L ${W} ${y0} `;
for (let x = W; x >= 0; x -= 12 + r() * 18) d += `L ${x.toFixed(1)} ${(y0 + depth * (0.3 + r() * 0.7)).toFixed(1)} `;
return d + `L 0 ${y0 + depth * 0.6} Z`;
};
const CdPage: React.FC<{p: CdProps; L: CdLayout; i: number; f: number; len: number; live: boolean; fiber: string | null; mottle: string | null}> = ({p, L, i, f, len, live, fiber, mottle}) => {
const face = L.faces[i], pg = L.pages[i], n = p.cuts.length;
const u = f / len;
const accentText = contrast(face.bg, p.colors.accent) >= 3 && !isYellow(hexRgb(p.colors.accent)) ? p.colors.accent : face.big;
const col = (c: CdEl['color']) => (c === 'big' ? face.big : c === 'accent' ? accentText : face.text);
const t0 = i === 0 ? 0 : 12;
const sunTop = lerp(pg.sunY0, pg.sunY1, ramp(f, t0, len, Easing.bezier(0.2, 0.2, 0.6, 0.9)));
const last = i === n - 1;
return (
<AbsoluteFill>
<Paper color={face.bg} mottle={mottle} fiber={fiber} fiberOpacity={0.6} vignette={0.05} box={{l: 0, t: BIND_B, w: W, h: H - BIND_B}} />
<div style={{position: 'absolute', left: 0, top: BIND_B, width: W, height: H - BIND_B, overflow: 'hidden'}}>
<div style={{position: 'absolute', left: pg.sunX - pg.sunD / 2, top: sunTop - BIND_B, width: pg.sunD, height: pg.sunD, borderRadius: '50%', backgroundColor: face.sun, filter: 'url(#inkFace)'}} data-gfx={live ? 'sun' : undefined} />
{last && <svg width={W} height={H} style={{position: 'absolute', left: 0, top: -BIND_B}}>
{Array.from({length: 18}, (_, q) => { const a = (q / 18) * Math.PI * 2 + (live ? f : len) * 0.011, r0 = pg.sunD / 2 + 34, r1 = r0 + 56 + (q % 2) * 34, cx = pg.sunX, cy = sunTop + pg.sunD / 2; const vis = live ? ramp(f, 10 + (q % 6) * 2, 22 + (q % 6) * 2) : 1;
return <line key={q} x1={cx + Math.cos(a) * r0} y1={cy + Math.sin(a) * r0} x2={cx + Math.cos(a) * (r0 + (r1 - r0) * vis)} y2={cy + Math.sin(a) * (r0 + (r1 - r0) * vis)} stroke={face.sun} strokeWidth={14} strokeLinecap="round" data-gfx={live ? 'ray' : undefined} />; })}
</svg>}
</div>
<T k={`brand${i}`} check={live} text={p.brand} font={PRET} weight={700} size={34} color={face.text} bg={face.bg} x={CL0} base={292} track={0.01} />
{Array.from({length: n}, (_, q) => <div key={q} style={{position: 'absolute', left: CR0 - (n - 1 - q) * 34 - 18, top: 268, width: 18, height: 18, borderRadius: '50%', boxSizing: 'border-box', border: `3px solid ${face.text}`, backgroundColor: q <= i ? face.text : 'transparent'}} />)}
<div style={{position: 'absolute', left: CL0, top: 326, width: CW0, height: 3, backgroundColor: face.rule}} />
<div style={{position: 'absolute', left: CL0, top: 324, width: CW0 * (live ? ramp(f, t0, len, Easing.linear) : 1), height: 7, backgroundColor: face.big}} />
{pg.els.map((e) => {
let s = 1, a = 1, dy = 0, dx = 0;
if (live) {
if (e.role === 'num' || e.role === 'pre') {
const st = ramp(f, t0, t0 + 7, BACK); const k = clamp01((f - t0 - 7) / Math.max(1, len - t0 - 7)); s = lerp(1.5, 1, st) * (1 + 0.035 * k); dx = (i % 2 ? -1 : 1) * 22 * k; a = ramp(f, t0, t0 + 3, Easing.linear);
} else if (e.role === 'line' || e.role === 'info') {
const st = t0 + 8 + e.q * 4 + (e.role === 'info' ? 14 : 0); const r = ramp(f, st, st + 10); a = r; dy = (1 - r) * 30;
} else if (e.role === 'word') {
const st = t0 + e.q * 7; const r = ramp(f, st, st + 8, BACK); s = lerp(1.4, 1, r) * (1 + 0.03 * u); a = ramp(f, st, st + 3, Easing.linear); dx = (e.align === 'right' ? -1 : 1) * 36 * u;
}
}
const m = ink(e.text, `${e.weight} ${e.px}px ${e.font}`, e.track * e.px);
const ox = e.role === 'num' ? (e.align === 'left' ? e.x + (m.r - m.l) / 2 : e.align === 'right' ? e.x - (m.r - m.l) / 2 : e.x) : e.x, oy = e.base - (m.asc - m.desc) / 2;
return <div key={e.k} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: a, transformOrigin: `${ox}px ${oy}px`, transform: `translate(${dx}px, ${dy}px) scale(${s})`}}>
<T k={`${e.k}-${i}`} g={i} check={live} inked={e.role === 'num' || e.role === 'pre'} hero={i === 0 && e.role === 'num'} glance={e.role === 'num' || e.role === 'pre' || e.role === 'word'} text={e.text} font={e.font} weight={e.weight} size={e.px} color={col(e.color)} bg={face.bg} x={e.x} base={e.base} align={e.align} track={e.track} minRatio={e.role === 'line' || e.role === 'info' ? 4.5 : 3} />
</div>;
})}
</AbsoluteFill>
);
};
const BANDS = 10;
const Curl: React.FC<{t: number; back: string; children: () => React.ReactNode}> = ({t, back, children}) => {
const h = (H - BIND_B) / BANDS;
const e = Easing.bezier(0.35, 0, 0.6, 1)(clamp01(t * 1.15));
const lift = ramp(t, 0.35, 1, Easing.bezier(0.5, 0, 0.9, 0.5));
let y = BIND_B, cum = 0, prevShade = 0;
const bands = Array.from({length: BANDS}, (_, k) => {
cum += 40 * Math.pow(k / (BANDS - 1), 1.5) * e;
const c = Math.cos(cum * Math.PI / 180), sn = Math.sin(cum * Math.PI / 180), y0 = BIND_B + k * h;
const shade = 0.34 * Math.max(0, sn) * (c < 0 ? 0.3 : 1);
const el = {k, y0, top: Math.min(y, y + h * c), hh: Math.abs(h * c), flip: c < 0, wide: 1 + 0.07 * Math.max(0, sn), s0: prevShade, s1: shade};
y += h * c; prevShade = shade; return el;
});
return <AbsoluteFill style={{transform: `translate(${-90 * lift}px, ${-2100 * lift}px) rotate(${-7 * lift}deg)`, transformOrigin: '50% 0'}}>
{bands.filter((b) => b.hh > 0.5).map((b) => <div key={b.k} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: `50% ${b.y0}px`, transform: `translateY(${b.top - b.y0}px) scale(${b.wide}, ${(b.hh + 0.8) / h})`}}>
<div style={{position: 'absolute', inset: 0, clipPath: `inset(${b.y0}px 0 ${H - b.y0 - h}px 0)`}}>
{b.flip ? <div style={{position: 'absolute', left: 0, top: b.y0, width: W, height: h, backgroundColor: back}} /> : children()}
<div style={{position: 'absolute', left: 0, top: b.y0, width: W, height: h, background: `linear-gradient(180deg, rgba(0,0,0,${(b.flip ? b.s1 : b.s0).toFixed(3)}), rgba(0,0,0,${(b.flip ? b.s0 : b.s1).toFixed(3)}))`}} />
</div>
</div>)}
</AbsoluteFill>;
};
const CdBody: React.FC<{p: CdProps; L: CdLayout; 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 TEAR = 13;
const tearing = i > 0 && f < TEAR;
const tp = clamp01(f / TEAR);
return (
<AbsoluteFill style={{backgroundColor: mix(p.colors.ink, '#000000', 0.3)}}>
<CdPage p={p} L={L} i={i} f={f} len={len} live fiber={fiber} mottle={mottle} />
{tearing && <div style={{position: 'absolute', left: 0, top: BIND_B, width: W, height: H - BIND_B, background: `linear-gradient(180deg, rgba(0,0,0,${0.22 * (1 - tp)}), rgba(0,0,0,0) 60%)`}} />}
{tearing && <Curl t={tp} back={mix(L.faces[i - 1].bg, '#ffffff', 0.12)}>{() => <CdPage p={p} L={L} i={i - 1} f={tl[i - 1].len} len={tl[i - 1].len} live={false} fiber={fiber} mottle={mottle} />}</Curl>}
<svg width={W} height={H} style={{position: 'absolute', inset: 0}}>
{Array.from({length: i}, (_, q) => q).filter((q) => !(tearing && q === i - 1)).map((q) => <path key={q} d={tornPath(q + 1, BIND_B - 2, 14 + (q % 2) * 7)} fill={mix(L.faces[q].bg, '#000000', 0.05)} />)}
</svg>
<div style={{position: 'absolute', left: 0, top: 0, width: W, height: BIND_B, backgroundColor: mix(p.colors.ink, '#000000', 0.2), boxShadow: '0 8px 16px rgba(0,0,0,0.35)'}} />
{[150, 930].map((x) => <div key={x} style={{position: 'absolute', left: x - 12, top: 64, width: 24, height: 24, borderRadius: '50%', background: 'radial-gradient(circle at 35% 35%, #d9d6cf, #6f6c66 70%)', boxShadow: '0 2px 3px rgba(0,0,0,0.5)'}} />)}
</AbsoluteFill>
);
};
const cdDefaults: CdProps = {
brand: '느린 잔', colors: {paper: '#EFE8DA', ink: '#1F1C19', accent: '#C2412D'},
cuts: [
{sec: 2.4, big: 'D-3', line: '을지로에 작은 바를 엽니다'},
{sec: 2.4, big: 'D-2', line: '잔은 열두 개만 놓았어요'},
{sec: 2.4, big: 'D-1', line: '내일 저녁 여섯 시'},
{sec: 3.6, big: '오늘 | 오픈', lines: ['10월 24일 토요일 저녁 6시', '서울 중구 을지로 15길 3']},
],
};
registerVideo<CdProps, CdLayout>({
slug: 'open-countdown-video', minCuts: 3, maxCuts: 5, minSec: 8, maxSec: 12, cutMin: 1.8, cutMax: 4,
fonts: (p) => ({css: CD_FONTS, specs: [['100px Anton', p.cuts.map((c) => c.big).join('') + 'D-0123456789'], ['100px "Black Han Sans"', p.cuts.at(-1)!.big.replace(/[|\s]/g, '')], [`800 100px ${PRET}`, p.brand + p.cuts.map((c) => (c.line ?? '') + (c.lines ?? []).join('')).join('')]]}),
setup: cdSetup,
paper: (p) => p.colors.paper,
texture: (p) => p.texture,
readChars: (p, i) => [...((p.cuts[i].line ?? '') + (p.cuts[i].lines ?? []).join(''))].filter((ch) => ch.trim()).length,
Body: CdBody as any,
extraCheck: (p, L) => {
const over: string[] = [], hint: string[] = [];
L.pages.forEach((pg, i) => pg.els.filter((e) => e.role === 'word').forEach((e) => { const c = counterIssues(e.text, (q) => `400 ${q}px ${GAS}`, e.px); if (c.length) { over.push('counter-closed'); hint.push(...c.map((x) => `컷 ${i + 1}: ${x}`)); } }));
L.pages.forEach((pg, i) => pg.split.forEach((ls) => { if ((p.cuts[i].line ?? p.cuts[i].big ?? '').includes('|')) return; const b = breakIssues(ls); if (b.length) { over.push('line-break'); hint.push(...b.map((x) => `컷 ${i + 1}: ${x}`)); } }));
return {overflow: over, hint, info: {faces: L.faces, lines: L.pages.map((pg) => pg.split), warn: L.warn}};
},
}, cdDefaults);
```
`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` 뜻: `safe-area`(글자 잉크가 위 12%·아래 35%·좌우 64px 밖 — 줄이 너무 길다), `contrast`(브랜드 색 조합 대비 부족 → 색을 바꿀지 사용자에게), `yellow-text`(노랑 글자 — 코드가 피하지만 걸리면 보고), `text-collide`(글자끼리 겹침 — 코드 문제), `read-time`(그 컷 글자에 비해 컷이 짧다 → 줄을 줄이거나 컷을 늘린다), `cut-count`·`cut-length`(컷 3~5개·컷마다 1.8~4초·합계 8~12초), `cut-similar`(두 컷 대표 화면이 거의 같다 — 코드 문제), `hook`·`hook-static`(첫 0.3초에 주인공 글자가 들어오지 않거나 0프레임이 이미 완성된 정지 화면 — 코드 문제)·`counter-closed`(큰 말 자형의 속공간이 막힘 — 코드 문제)·`gfx-collide`(그래픽이 글자에 걸림 — 코드 문제, `gfx.sample` 에 프레임·글자)·`text-overlap`(전환 중 글자 겹침 프레임 — 코드 문제, `overlap.sample` 에 프레임·글자)·`line-break`(코드가 나눈 줄이 수+단위·관형사+명사 사이에서 바뀜 — 코드 문제. 사용자가 ` | ` 로 직접 나눈 줄은 검사하지 않는다)·`motion-still`(컷 안에서 0.8초 넘게 거의 멈춤 — 코드 문제), `audio`(소리 트랙 — 코드 문제). `cuts[].items` 에 글자마다 잉크 상자·크기·색·대비, `cuts[0].info.faces` 에 장마다 면 색, `cuts[0].info.lines` 에 코드가 나눈 줄, `similar`·`motion`(컷마다 `longestStillSec`)·`mp4`(초·크기·`audio: false`)가 남는다.
2. `out/cut1-b.png` ~ 마지막 컷 `-b.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구): 글자가 입력과 글자 하나까지 같은지(띄어쓰기·받침 포함), 숫자가 D-3 → D-2 → D-1 로 줄어드는지, 숫자·한 줄 자리가 컷마다 번갈아 바뀌는지, 마지막 장에 큰 말·날짜·장소·브랜드가 있는지, 면 색이 장마다 바뀌는지, 글자가 해(원)·햇살에 걸리지 않는지.
3. MP4 이면 `out/hook0.png`(0초 — 빈 장이거나 숫자가 찍히는 중)·`out/hook.png`(0.3초 — `D-3` 이 다 보임)와 `out/frames/c<n>-a|b|c.png` 를 본다: `c2-a`·`c3-a`·`c4-a`(컷 시작 0.13초)에 앞 장이 말려 올라가며 걷히는 중인지(새 장 숫자는 아직 없음). 영상은 props 합계 초, 1080×1920, 30fps, 소리 없음.
4. 문제가 있으면 원인(글자 길이·props)을 고쳐 다시 렌더한다.
## 9. 보고
결과 파일 경로(`out/video.mp4`, `out/cut<n>-b.png`), 질감 판을 썼는지(어떻게 얻었는지), check.json 의 컷별 면 색·숫자 크기·최소 대비·`similar.max`·`motion`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 그리고 한 줄: **"이 영상은 무음입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 저작권 걱정 없는 음원으로 붙이세요."**
폰트(모두 SIL OFL 1.1, 상업 사용 가능): 숫자 Anton · 마지막 큰 말 Black Han Sans · 작은 글자 Pretendard.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Codex(권장)나 Claude Code 에 붙여 넣고 브랜드·색·컷별 글자를 알려 주세요. 이미지 생성 없이 코드만으로 렌더해서 어느 쪽이든 그대로 됩니다.
- 올리기
MP4 파일을 받아 릴스·쇼츠로 올리세요. 음악은 편집 앱에서 붙이세요.
컷 구성
- 컷 1 · 2.4초 · D-3
- 빈 일력 장에 큰 D-3 이 찍히듯 내려앉고, 한 줄이 올라오며 아래에서 해가 뜨기 시작한다
- 컷 2 · 2.4초 · D-2
- 앞 장이 말려 올라가며 찢겨 나가고 다음 장 — 면 색과 숫자·한 줄 자리가 번갈아 바뀐다
- 컷 3 · 2.4초 · D-1
- 하루가 갈수록 해가 더 높이 뜨고, 남은 날 점과 진행선이 찬다
- 컷 4 · 3.6초 · 오늘
- 큰 말 두 줄(오늘 / 오픈)이 한 줄씩 찍히고 날짜·장소 두 줄, 다 떠오른 해에 햇살이 뻗는다
예시 영상의 컷입니다. 레시피는 컷 3~5개 · 합계 8~12초 안에서 내가 준 항목 수에 맞춰 컷을 나눕니다.
지킬 것
- 잘 맞는 때: 가게·공간·서비스를 여는 날을 며칠 앞두고 하루에 한 장씩 알릴 때
- 날짜·시간·장소는 내가 알려 준 값만 씁니다 — 주지 않은 사실을 지어 넣지 않게 돼 있어요.
- 한 줄은 18자 안팎이 넉넉합니다. 길면 코드 검사가 멈추고 줄인 안을 물어봐요.
- 무음 MP4 입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 붙이세요.


