← 영상
영상
행사 안내
코드 렌더 · 같은 결과
무엇·언제·어디·참여를 장면마다 멈춰 읽히고, 다음 장면이 같은 속도로 밀고 올라옵니다.
계기행사
가상 브랜드 볕뜰 상회로 레시피를 실제 실행해 렌더한 영상입니다. 소리는 없습니다.
레시피 보기86,072자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 영상 「행사 안내」 9:16 · 장면 3~4개 · 약 12~15초 (무엇 → 언제·어디 → 참여 → 맺음)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. **AI 영상 생성을 쓰지 않는다.** 글자·면·선·움직임을 코드(React + HyperFrames)가 실제 폰트로 그려 세로 MP4 한 편으로 렌더한다.
- 1080×1920 · **60fps · 서브프레임 4장 모션 블러**(한 프레임 안에서 시간을 1/240초씩 비켜 그린 4장을 평균 — 움직일 때만 번지고 멈추면 칼같이 선명) · **무음**(음악은 편집 앱에서)
- **레퍼런스 문법**(실제 행사 예고 영상을 프레임 단위로 뜯어 잰 값): 장면마다 글자가 아래에서 빨리 들어와 **완전히 멈춰 읽게 하고**(1.3~2초), 가속하며 위로 빠지는 순간 다음 장면이 **같은 속도로 밀고 올라온다**(페이드 없음). 마지막엔 포인트색 면이 화면을 채웠다가 띠 → 세 줄 → 추상 마크로 휘고 브랜드명·요약줄로 끝난다. 어두운 바탕·밝은 글자·포인트색 한 개, 가운데 정렬, 장면당 요소 2~5개. 다양함은 요소 수가 아니라 **대비**(세로로 오르는 글자 ↔ 옆에서 들어오는 표식, 보통 ↔ 굵게, 글자색 ↔ 포인트색)와 **변형**(면 → 선 → 마크)에서 나온다.
- 코드 검사: 입력 빈칸·색·수 → 장면마다 **정지 구간**(시작·가운데·끝)에서 글자 정확성(화면 줄을 이으면 입력과 한 글자도 다르지 않음)·안전 영역(위 12%·아래 35%·좌우 64px)·대비·큰 글자 크기 하한·속공간·글자끼리/그래픽과 겹침 → MP4 에서 **움직임 프로파일**(멈춤 40~65%, 느린 흐름 30% 이하)·**장면당 정지 1.0~2.2초**·**튀는 프레임 0**·규격·무음. 전환 중 두 장면이 잠깐 겹쳐 보이는 밀기는 의도라서 검사하지 않는다(정지 화면의 겹침만 실패).
원본 템플릿: https://duchamp.app/readymade/videos/event-info-video
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "밝은 색(종이), 어두운 색(먹), 포인트색" HEX 3개. 어두운 바탕 + 밝은 글자로 쓴다(어두운 색이 없으면 코드가 반전). 비우면 #ECE8DF, #161616, #E0442B
주제: (내 브랜드에 맞는 주제 하나) # 어떤 행사인지. 예: 연남동 앞마당 벼룩장
넣을 글자: (주제에 맞춰 분량 상한 안에서 컷별 글자를 지어 넣는다) # 칸마다. "큰:" = 큰 글자, "줄:" = 작은 줄. 빈 칸은 비워 둔다(지어 넣지 않는다)
# 무엇: 큰: 벼룩장 / 줄: 이웃 열다섯 집이 물건을 내놓아요
# 언제: 큰: 11.08 — 11.09 / 줄: 일·월 낮 12시부터 5시까지 ← 날짜는 꼭 있어야 한다(하나여도 됨: 10.24)
# 어디: 큰: 연남 / 줄: 마포구 성미산로 17길 3, 앞마당 ← 없으면 언제만
# 참여: 큰: 선착순 | 30명 / 줄: 장바구니를 가져오면 꽃 한 송이를 드려요 ← 없으면 장면을 뺀다
인원: 없음 # 무엇 장면의 점 개수(집·팀·작가 수, 2~30). 사용자가 숫자로 적어 준 것만. 예: 15
정원: 없음 # 참여 장면의 자리 칸 수(2~60). 사용자가 숫자로 적어 준 것만. 예: 30
맺음 마크: 자동 # 호(볕·하루·무지개) | 고리(모임·책·대화) | 막대(장터·성장·여러 가게). 자동이면 주제에 맞춰 셋 중 하나
출력: MP4 # MP4(끝까지, 멈추지 않음) | 계획 먼저(비트맵·정지 화면을 먼저 보여 주고 승인 뒤 MP4) | 계획만
질감 판: 없음 # 없음 | 자동 | 내 사진: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 브랜드명·글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 날짜·시간·장소·혜택·인원 같은 사실은 사용자가 준 것만 — 주지 않은 칸은 `null` 로 두고 지어 넣지 않는다.
- **`인원`·`정원` 은 그 줄에 적힌 숫자만 쓴다.** 글자 속 숫자("열다섯 집", "30명", "2시")에서 추측해 채우지 않는다. 없음이면 `null`(점·자리 칸 없이 글자만).
- 맺음 마크는 세 추상 마크 중 하나만 고른다. 브랜드 로고·심볼을 지어 그리지 않는다. 요약줄(날짜 · 장소)은 코드가 언제·어디 큰 글자로 만든다.
- 줄바꿈·크기·정지 길이는 코드가 정한다(§3). 큰 글자가 너무 길어 코드가 실패하면(`big-small`) 줄인 안을 사용자에게 물어본다.
- **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. 스튜디오 패키지 (코드가 이대로 구현돼 있다 — 고치지 않는다. 확인·보고의 기준)
**(a) 레퍼런스 문법 — 타이밍표 핵심 수치**
| 항목 | 값 |
|---|---|
| 들어옴 | 지수 감속: 30fps 한 프레임마다 남은 거리 ×0.85(τ 0.205초), 오버슈트 0. 거리 648~675px 아래에서 |
| 투명도 | 줄마다 0.2~0.23초 계단(묶음은 한 번에 오르고 투명도만 차례로) |
| 정지 | 장면마다 완전 정지 1.3~2초 = 0.8초 + 글자당 0.03초(1.3~1.8초) + 들어옴 꼬리 |
| 나감 | 지수 가속: 한 프레임마다 ×1.23(τ 0.161초). 라벨은 0.07초 먼저·1.3배 빠르게 |
| 전환 | 밀기 한 번: 다음 장면이 0.6초 뒤 같은 방향·같은 속도로(나감 끝 속도 = 들어옴 첫 속도). 두 장면이 약 5프레임 같이 보임 |
| 맺음 | 면 0.3초에 모서리 너머까지 → 0.45초 정지 → 띠로 settle(스프링 2.6Hz·감쇠 0.9) → 세 줄 → 0.75초 마크로 휨, 색 3단 → 한 색 |
| 구도 | 가운데 정렬, 묶음 중심 = 안전 영역 가운데(y 739), 글자 폭 최대 900px. 큰 글자 세리프(Noto Serif KR), 작은 줄 Pretendard |
| 프로파일 | 레퍼런스 멈춤/느린 흐름/큰 움직임 = 53/15/32%. 이 코드 실측 56~61 / 16~24 / 15~26% |
**(b) 상태·비트 표** (120BPM, 비트 = 0.5초. 실제 시각은 `out/beats.md` 에 입력별로 나온다)
| 장면 | 보는 사람이 아는 것 | 진입 | 퇴장 | 존재 이유 |
|---|---|---|---|---|
| 1 무엇 | 누가(브랜드) 무엇을(행사 이름·한 줄) | 라벨 → 글자 묶음 상승 + 투명도 계단, (인원) 점·배지가 좌우에서 수평으로 | 가속해 위로 | 행사의 정체 |
| 2 언제·어디 | 날짜(보통)·장소(굵게)·시간·주소 | 아래에서 같은 속도로 밀고 올라옴 | 같음 | 갈 수 있는지 판단 |
| 3 참여 | 조건(보통 + 포인트색 굵게)·혜택, (정원) 빈 자리 칸 | 같음, 칸은 왼쪽에서 수평으로 | 면이 덮음 | 행동 조건 |
| 4 맺음 | "정보 끝, 이 이름" — 브랜드명 + 날짜 · 장소 | 면 → 띠 → 세 줄 → 마크 | 끝(정지) | 기억할 이름 |
장소가 없으면 2는 날짜만, 참여가 없으면 3을 뺀다(장면 3개).
**(c) 물체 종류별 모션 규칙**
| 물체 | 규칙 |
|---|---|
| 큰 글자 줄 | 묶음으로 빨리 들어와 완전히 정지. 줄마다 따로 움직이지 않는다. 투명도만 계단 |
| 작은 줄 | 큰 줄 묶음과 같이 움직이고 투명도만 늦게 |
| 작은 표식(점·자리 칸) | 이것만 스프링 snap(4Hz, 감쇠 0.55), 하나씩 시차. 옆에서 수평으로 진입(축 대비) |
| 큰 면(채움·띠) | 0.3초 안에 끝나는 베지어 → 띠로 settle(넘침 없음) |
| 형태 변형(선 → 마크) | 0.75초, 끝이 먼저 휨 |
| 카메라·흔들림·페이드 전환 | 없음 |
**(d) 검수 체크리스트** (`out/still-<n>.png`·`out/contact.png` 를 직접 보고)
1. 글자가 입력과 글자 하나까지 같다(받침·숫자·점·띄어쓰기). 줄이 수+단위("열다섯 집")·관형사+명사("한 송이") 사이에서 갈리지 않았다.
2. 장면마다 요소 2~5개, 화면 가운데 위쪽(안전 영역)에 묶여 있고 아래 35% 는 비어 있다.
3. 포인트색은 장면마다 한 곳(무엇 = 행사 이름, 참여 = 수가 든 줄, 맺음 = 면·마크). 글자가 바탕에 묻히지 않는다.
4. 컨택트 시트에서 장면마다 같은 화면이 3~4칸 이어진다(= 읽을 정지). 전환 칸에서 글자가 위로 번지며 빠지고 다음 장면이 아래에서 올라온다. 면 → 띠 → 세 줄 → 마크 순서가 보인다.
5. 인원·정원을 줬을 때만 점·자리 칸이 있고, 개수가 그 숫자와 같다(배지 숫자 확인).
**(e) 게이트 — 전체 렌더 전에 승인받기 (대화형일 때만, 대화형인지는 `출력` 칸이 정한다)**
- `출력: 계획 먼저` 일 때만: `node render.mjs plan` 으로 비트맵(`out/beats.md`)·정지 화면(`out/still-1..n.png`)·컨택트 시트를 만들고, 비트맵 표와 정지 화면(장면 수만큼, 보통 4장)을 사용자에게 보여 "이대로 전체 렌더할까요?" 승인을 받은 뒤 `node render.mjs mp4`.
- `출력: MP4`(기본)이면 **게이트를 건너뛴다 — 승인을 묻거나 멈추지 말고** `node render.mjs mp4` 까지 실행한다(에이전트가 스스로 "대화형"이라 판단해 멈추지 않는다 — 한 번 실행에서는 답할 사람이 없다). 비트맵·정지 화면·컨택트 시트는 그대로 `out/` 에 남고, 보고에 정지 화면과 경로를 보여 준다.
## 3. 범용 입력 규칙 (코드가 처리 — 알고만 있으면 된다)
- **줄 나눔**: 큰 글자·무엇의 줄은 8자(빈칸 빼고) 이상이면 두 줄로, 작은 줄은 19자 이상이면 두 줄로 — 의미 단위로, 두 줄 모두 3자 이상, 수+단위·관형사+명사는 붙인다. 날짜(`11.08 — 11.09`)는 15자 미만이면 한 줄. 사용자가 ` | ` 로 나눈 곳은 그대로.
- **크기**: 한 장면의 큰 줄은 같은 크기로, 가장 긴 줄이 900px 에 맞게(무엇 최대 150px, 나머지 170px). 64px 아래로 내려가야 하면 `big-small` 로 멈춘다.
- **정지 길이 = 글자 수 비례**: 0.8초 + 글자당 0.03초(1.3~1.8초) — 긴 장면이 더 오래 멈춘다. 장면 경계는 0.25초 격자.
- **칸이 빠질 때**: 날짜 하나 → 한 줄 날짜. 장소 없음 → 언제 장면에 날짜만, 요약줄도 날짜만. 참여 없음 → 장면 3 을 빼고 맺음으로. 참여에 큰 글자가 없으면 줄을 큰 글자로.
- **수**: `인원`(2~30) → 무엇 장면에 점 n 개 + 숫자 배지. `정원`(2~60) → 참여 장면에 빈 자리 칸 n 개. 범위 밖이면 `count-range` 로 멈춘다(숫자를 확인해 다시 묻는다).
- **색**: 어두운 색을 바탕으로, 밝은 색을 글자로. 어두운 색이 없으면(밝은 종이색뿐) **반전**: 종이색 바탕 + 먹색 글자. 포인트색 글자(행사 이름·수)는 바탕과 대비 3 이상이 될 때까지 글자색 쪽으로 섞는다(면·마크는 원래 포인트색). 포인트색이 바탕과 거의 같으면 `accent-flat` 으로 멈춘다.
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"colors": {"paper": "<밝은 색>", "ink": "<어두운 색>", "accent": "<포인트색>"},
"what": {"big": "<무엇 큰 글자>", "line": "<무엇 줄 또는 null>", "people": null},
"when": {"big": "<날짜 — 예: 11.08 — 11.09>", "line": "<시간 줄 또는 null>"},
"where": {"big": "<장소 큰 글자>", "line": "<주소 줄>"},
"join": {"big": "<예: 선착순 | 30명>", "line": "<참여 줄>", "seats": null},
"mark": "arc"
}
```
- 글자는 입력 그대로(` | `·` — ` 도 그대로). 없는 칸은 `null`, 장소가 통째로 없으면 `"where": null`, 참여가 없으면 `"join": null`.
- `people` = `인원` 숫자 또는 `null`, `seats` = `정원` 숫자 또는 `null`. `mark`: 호 = `"arc"`, 고리 = `"ring"`, 막대 = `"bars"`.
- 길이·시각·크기는 넣지 않는다(코드가 글자 수로 정한다).
## 5. 파일 구조와 코드 (현재 폴더에 만든다. 코드는 그대로 저장)
```
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/beats.md out/still-<n>.png out/contact.png out/check.json
```
`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-variable/noto-serif-kr": "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 {ff, meta, render} from './hf.mjs';
const mode = process.argv[2] ?? 'mp4';
const props = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.rmSync('out', {recursive: true, force: true});
fs.mkdirSync('out/_b', {recursive: true});
const over = [], hints = [];
const check = {ok: false, overflow: [], hint: null};
const save = () => { check.overflow = [...new Set(over)]; check.ok = check.overflow.length === 0; check.hint = [...new Set(hints)].join(' / ') || null; fs.writeFileSync('out/check.json', JSON.stringify(check, null, 2)); };
const stop = async () => { save(); console.log('CHECK', check.ok ? 'ok' : 'FAIL ' + JSON.stringify(check.overflow), '→ out/check.json'); process.exit(check.ok ? 0 : 2); };
const hex = /^#[0-9a-fA-F]{6}$/, txt = (x) => typeof x === 'string' && x.trim() !== '';
if (!txt(props.brand)) { over.push('input'); hints.push('brand 가 비었다'); }
for (const k of ['paper', 'ink', 'accent']) if (!hex.test(props.colors?.[k] ?? '')) { over.push('input'); hints.push(`colors.${k} 는 #RRGGBB`); }
if (!txt(props.what?.big)) { over.push('input'); hints.push('what.big(무엇 큰 글자)이 비었다'); }
if (!txt(props.when?.big)) { over.push('input'); hints.push('when.big(날짜)이 비었다 — 날짜는 꼭 있어야 한다'); }
for (const [k, v, a, b] of [['what.people', props.what?.people, 2, 30], ['join.seats', props.join?.seats, 2, 60]]) if (v != null && !(Number.isInteger(v) && v >= a && v <= b)) { over.push('count-range'); hints.push(`${k} = ${v}: ${a}~${b} 정수이거나 null(입력에 명시된 수만 — 문장에서 추측하지 않는다)`); }
if (props.mark != null && !['arc', 'bars', 'ring'].includes(props.mark)) { over.push('input'); hints.push('mark 는 arc | bars | ring'); }
if (props.texture && !fs.existsSync(path.join('public', props.texture))) { over.push('texture-missing'); hints.push(`public/${props.texture} 파일이 없음`); }
if (over.length) await stop();
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 (type === 'IDAT') idat.push(d); if (type === 'IEND') break; p += 12 + len; }
const bpp = {0: 1, 2: 3, 4: 2, 6: 4}[ct], raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * bpp, 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 rgb = Buffer.alloc(w * h * 3); for (let i = 0; i < w * h; i++) for (let c = 0; c < 3; c++) rgb[i * 3 + c] = px[i * bpp + (bpp >= 3 ? c : 0)];
return {w, h, rgb};
};
const CRC = Array.from({length: 256}, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const chunk = (type, data) => { const t = Buffer.from(type), l = Buffer.alloc(4), c = Buffer.alloc(4); l.writeUInt32BE(data.length); let x = 0xffffffff; for (const v of Buffer.concat([t, data])) x = CRC[(x ^ v) & 255] ^ (x >>> 8); c.writeUInt32BE((x ^ 0xffffffff) >>> 0); return Buffer.concat([l, t, data, c]); };
const writePng = (file, w, h, rgb) => { const ih = Buffer.alloc(13); ih.writeUInt32BE(w); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 2; const raw = Buffer.alloc((w * 3 + 1) * h); for (let y = 0; y < h; y++) rgb.copy(raw, y * (w * 3 + 1) + 1, y * w * 3, (y + 1) * w * 3);
fs.writeFileSync(file, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])); };
const t0 = Date.now(), sec = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
const checks = [];
const onBrowserLog = (l) => { if (l.text.startsWith('DUCHAMP_CHECK ')) checks.push(JSON.parse(l.text.slice(14))); else if (l.type === 'error') console.error('[browser]', l.text); };
const comp = await meta(props);
const {plan, palette, checks: cf} = comp.props, fps = comp.fps;
// 여러 프레임을 한 번에(프레임 목록 렌더). 실패면 멈춘다
const stills = async (name, list) => { const r = await render({name, props, m: comp, format: 'png', frames: list.map((x) => x[0]), out: list.map((x) => x[1])}); if (r.fail) { console.error('렌더 실패:', r.fail); process.exit(1); } r.logs.forEach((text) => onBrowserLog({text, type: 'log'})); };
const b = (x) => (x / 0.5).toFixed(1).replace(/\.0$/, '');
const rows = plan.scenes.map((s, i) => `| ${i + 1} ${s.label} | ${s.tIn.toFixed(2)}–${s.X.toFixed(2)}초 (비트 ${b(i ? plan.scenes[i - 1].X : 0)}–${b(s.X)}) | ${s.items.map((x) => x.text).join(' / ')} | ${i === 0 ? '라벨 → 묶음 상승 + 투명도 계단' : '아래에서 같은 속도로 밀고 올라옴'}${s.people ? ` · 점 ${s.people}개 + 배지` : ''}${s.seats ? ` · 자리 ${s.seats}칸` : ''} | 정지 ${(s.X - s.settle).toFixed(2)}초 (글자 ${s.chars}자) | 비트 ${b(s.X)}에 가속 퇴장 |`);
rows.push(`| ${plan.scenes.length + 1} 맺음 | ${plan.close.F.toFixed(2)}–${plan.close.end.toFixed(2)}초 (비트 ${b(plan.close.F)}–${b(plan.close.end)}) | ${props.brand} / 요약줄 | 면 0.3초 채움 → 띠 → 세 줄 → 마크(${props.mark ?? 'arc'}) | 정지 ${(plan.close.end - plan.close.settle).toFixed(2)}초 | 끝(정지) |`);
fs.writeFileSync('out/beats.md', `# 비트맵 · ${plan.end}초 · ${fps}fps · 바탕 ${palette.bg} 글자 ${palette.fg} 포인트 ${palette.accent}${palette.inverted ? ' (반전)' : ''}\n\n| 장면 | 시간 | 보는 사람이 아는 것 | 진입 | 정지 | 퇴장 |\n|---|---|---|---|---|---|\n${rows.join('\n')}\n`);
console.log(fs.readFileSync('out/beats.md', 'utf8'));
await stills('checks', cf.map((c, i) => [c.frame, c.at === 'mid' ? `out/still-${c.scene}.png` : `out/_b/x${i}.png`]));
const size = `${comp.width}x${comp.height}`;
check.video = {seconds: plan.end, fps, size, palette};
check.scenes = cf.map((c) => checks.find((x) => x.frame === c.frame) ?? {...c, over: ['no-check'], hint: ['검사 기록 없음']});
for (const c of check.scenes) { over.push(...c.over); hints.push(...c.hint.map((h) => `장면 ${c.scene}(${c.at}): ${h}`)); }
if (palette.accentFlat) { over.push('accent-flat'); hints.push('포인트색이 바탕과 거의 같다 — 더 진한(또는 밝은) 포인트색을 사용자에게'); }
console.log(`정지 화면 out/still-1..${plan.scenes.length + 1}.png ${sec()}`);
const beats = []; for (let x = 0.5; x <= plan.end + 1e-6; x += 0.5) beats.push(Math.min(Math.round(x * fps), comp.durationInFrames - 1));
// 비트마다 한 장 → 0.2배(216×384)로 줄여 컨택트 시트에
await stills('beats', beats.map((f, i) => [f, `out/_b/full-${String(i).padStart(3, '0')}.png`]));
for (let i = 0; i < beats.length; i++) { const k = String(i).padStart(3, '0'); ff(['-v', 'error', '-y', '-i', path.resolve(`out/_b/full-${k}.png`), '-vf', 'scale=iw*0.2:ih*0.2:flags=area', path.resolve(`out/_b/${k}.png`)]); }
{ const tiles = beats.map((_, i) => readPng(`out/_b/${String(i).padStart(3, '0')}.png`)), tw = tiles[0].w, th = tiles[0].h, C = 8, G = 6, R = Math.ceil(tiles.length / C);
const SW = C * tw + (C + 1) * G, SH = R * th + (R + 1) * G, s = Buffer.alloc(SW * SH * 3, 128);
tiles.forEach((tl, i) => { const ox = G + (i % C) * (tw + G), oy = G + Math.floor(i / C) * (th + G); for (let y = 0; y < th; y++) tl.rgb.copy(s, ((oy + y) * SW + ox) * 3, y * tw * 3, (y + 1) * tw * 3); });
writePng('out/contact.png', SW, SH, s); }
console.log(`컨택트 시트 out/contact.png (비트마다 1장, 왼→오른·위→아래, ${beats.length}장) ${sec()}`);
save();
if (!check.ok || mode !== 'mp4') { fs.rmSync('out/_b', {recursive: true, force: true}); await stop(); }
let lastP = -1;
const v = await render({name: 'video', props, m: comp, format: 'mp4', crf: 16, 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); }
// 움직임 프로파일용 회색 축소판 — 가로 96, 세로는 비율대로(짝수). 9:16 = 96×170
const PH = Math.round(96 * comp.height / comp.width / 2) * 2;
fs.mkdirSync('out/_m'); ff(['-v', 'error', '-i', path.resolve('out/video.mp4'), '-vf', `scale=96:${PH}:flags=area,format=gray`, path.resolve('out/_m/%05d.png')]);
const fr = fs.readdirSync('out/_m').sort().filter((_, i) => i % Math.round(fps / 30) === 0).map((f) => readPng(path.join('out/_m', f)).rgb), NP = 96 * PH;
const d = []; for (let i = 1; i < fr.length; i++) { let s = 0; for (let k = 0; k < NP; k++) s += Math.abs(fr[i][k * 3] - fr[i - 1][k * 3]); d.push(s / NP); }
if (d.length < 30) { over.push('profile'); hints.push('MP4 프레임을 다시 읽지 못함'); }
const pct = (f) => +(100 * d.filter(f).length / d.length).toFixed(1);
const prof = {still: pct((x) => x < 0.3), slow: pct((x) => x >= 0.3 && x < 2), big: pct((x) => x >= 2), pops: 0, scenes: []};
for (let i = 1; i < d.length - 1; i++) if (d[i] > 1 && d[i] > 3 * Math.max(d[i - 1], d[i + 1])) { prof.pops++; (prof.popAt ??= []).push(+((i + 1) / 30).toFixed(2)); }
const win = [...plan.scenes.map((s, i) => [i ? plan.scenes[i - 1].X : 0, s.X]), [plan.close.F, plan.end]];
for (const [a, z] of win) { let run = 0, best = 0; for (let i = Math.floor(a * 30); i < Math.min(d.length, Math.floor(z * 30)); i++) { run = d[i] < 0.3 ? run + 1 : 0; best = Math.max(best, run); } prof.scenes.push(+(best / 30).toFixed(2)); }
check.profile = prof;
if (prof.still < 40 || prof.still > 65) { over.push('profile'); hints.push(`멈춤 ${prof.still}% — 40~65% 밖(코드 문제)`); }
if (prof.slow > 30) { over.push('profile'); hints.push(`느린 흐름 ${prof.slow}% > 30%(코드 문제)`); }
prof.scenes.forEach((s, i) => { if (s < 1.0 || s > 2.2) { over.push('hold'); hints.push(`장면 ${i + 1} 정지 ${s}초 — 1.0~2.2초 밖(글자 수에 비해 너무 짧거나 길다)`); } });
if (prof.pops) { over.push('pop'); hints.push(`튀는 프레임 ${prof.pops}개(${prof.popAt.join(', ')}초) — 코드 문제`); }
const info = ff(['-i', path.resolve('out/video.mp4')]).stderr ?? '', dm = /Duration: (\d+):(\d+):([\d.]+)/.exec(info);
check.mp4 = {file: 'out/video.mp4', seconds: dm ? +dm[1] * 3600 + +dm[2] * 60 + +dm[3] : null, size: /(\d{3,4})x(\d{3,4})/.exec(info)?.[0] ?? null, fps: +(/([\d.]+) fps/.exec(info)?.[1] ?? 0), audio: /Audio:/.test(info)};
if (check.mp4.audio) { over.push('audio'); hints.push('MP4 에 소리 트랙이 있음'); }
if (check.mp4.size !== size || check.mp4.fps !== 60) { over.push('format'); hints.push(`MP4 ${check.mp4.size} ${check.mp4.fps}fps — ${size} 60fps 가 아님`); }
for (const x of ['out/_b', 'out/_m']) fs.rmSync(x, {recursive: true, force: true});
console.log(`MP4 out/video.mp4 ${check.mp4.seconds}s · ${check.mp4.size} · ${check.mp4.fps}fps · 소리 ${check.mp4.audio ? '있음(!)' : '없음'} · 멈춤/느린/큰 ${prof.still}/${prof.slow}/${prof.big}% · 장면 정지 ${prof.scenes.join('/')}초 · 튐 ${prof.pops} ${sec()}`);
await stop();
```
`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` (앞부분 = 공용 모션 라이브러리, 시간 t 의 순수 함수만)
```tsx
import React, {useEffect, useMemo, useState} from 'react';
import {AbsoluteFill, Composition, Img, cancelRender, continueRender, delayRender, registerRoot, staticFile, useCurrentFrame, useVideoConfig} from './duchamp-frame';
const tauOf = (perFrame: number) => 1 / 29.97 / Math.abs(Math.log(perFrame));
const TAU_IN = tauOf(0.85), TAU_OUT = tauOf(1.23);
const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const inOff = (t: number, t0: number, D: number, tau = TAU_IN) => Math.min(D * Math.exp(-(t - t0) / tau), D * 40);
const outOff = (t: number, X: number, A: number, tau = TAU_OUT) => (t <= X ? 0 : -Math.min(A * (Math.exp((t - X) / tau) - 1), 6000));
const matchA = (D: number, gap: number) => D * (TAU_OUT / TAU_IN) * Math.exp(-gap / TAU_OUT);
const scene = (t: number, tIn: number, X: number, D: number, gap = 0.6) => inOff(t, tIn, D) + outOff(t, X, matchA(D, gap));
const spring = (t: number, t0: number, f = 2.2, z = 0.75) => {
const x = t - t0; if (x <= 0) return 0;
const w = 2 * Math.PI * f;
if (z >= 1) return 1 - Math.exp(-w * x) * (1 + w * x);
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * x) * (Math.cos(wd * x) + (z * w / wd) * Math.sin(wd * x));
};
const springs = (t: number, base: number, steps: {t0: number; to: number; f?: number; z?: number}[]) => {
let v = base, prev = base;
for (const s of steps) { v += (s.to - prev) * spring(t, s.t0, s.f, s.z); prev = s.to; }
return v;
};
const bez = (x1: number, y1: number, x2: number, y2: number) => (x: number) => {
if (x <= 0) return 0; if (x >= 1) return 1;
let lo = 0, hi = 1, s = 0.5;
for (let i = 0; i < 30; i++) { s = (lo + hi) / 2; const bx = 3 * (1 - s) ** 2 * s * x1 + 3 * (1 - s) * s * s * x2 + s ** 3; if (bx < x) lo = s; else hi = s; }
return 3 * (1 - s) ** 2 * s * y1 + 3 * (1 - s) * s * s * y2 + s ** 3;
};
const hexRgb = (h: string) => { const n = parseInt(h.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const mix = (a: string, b: string, k: number) => '#' + hexRgb(a).map((v, i) => Math.round(lerp(v, hexRgb(b)[i], k)).toString(16).padStart(2, '0')).join('');
const morphPath = (n: number, line: (u: number) => number[], target: (u: number) => number[], M: number, lag = 0.35) => {
const pts: string[] = [];
for (let i = 0; i <= n; i++) {
const u = i / n, e = Math.abs(2 * u - 1);
let m = clamp(M * (1 + lag) - lag * (1 - e)); m = m * m * (3 - 2 * m);
const a = line(u), b = target(u);
pts.push(`${lerp(a[0], b[0], m).toFixed(2)},${lerp(a[1], b[1], m).toFixed(2)}`);
}
return 'M' + pts.join(' L');
};
const W = 1080, H = 1920, SAFE_T = Math.round(H * 0.12), SAFE_B = Math.round(H * 0.65), SIDE = 64, MID = (SAFE_T + SAFE_B) / 2, MAXW = 900;
const SERIF = (w: number, px: number) => `${w} ${px}px "Noto Serif KR"`;
const SANS = (w: number, px: number) => `${w} ${px}px "Pretendard Variable"`;
const FONT_CSS = ['https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;800&display=block', 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css'];
const D1 = 648, D2 = 675, GAP = 0.6, MIN_BIG = 64;
type Field = {big?: string | null; line?: string | null};
type Props = {brand: string; colors: {paper: string; ink: string; accent: string}; what: Field & {people?: number | null}; when: Field; where?: Field | null; join?: (Field & {seats?: number | null}) | null; mark?: 'arc' | 'bars' | 'ring'; texture?: string | null; sub?: number; fps?: number};
type Item = {f: string; text: string; role: 'label' | 'serif' | 'big' | 'sub'; w: number; accent?: boolean};
type Scene = {key: string; label: string; items: Item[]; tIn: number; step: number; settle: number; X: number; hold: number; chars: number; people?: number; seats?: number};
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 nch = (s: string) => s.replace(/\s/g, '').length;
const norm = (s: string) => s.replace(/\s*\|\s*/g, ' ').replace(/\s+/g, ' ').trim();
const lines = (s: string | null | undefined, minSplit: number): string[] => {
const x = (s ?? '').trim(); if (!x) return [];
if (x.includes('|')) return x.split('|').map((y) => y.trim()).filter(Boolean);
const w = x.split(/\s+/); if (nch(x) < minSplit || w.length < 2) return [x];
let best: string[] = [x], sc = Infinity;
for (let a = 1; a < w.length; a++) {
if (glued(w[a - 1])) continue;
const A = w.slice(0, a).join(' '), B = w.slice(a).join(' ');
if (nch(A) < 3 || nch(B) < 3) continue;
const v = Math.abs(nch(A) - nch(B)) + (SOFT_END.test(w[a - 1]) ? 0 : 6);
if (v < sc) { sc = v; best = [A, B]; }
}
return best;
};
const holdFor = (chars: number) => clamp(0.8 + 0.03 * chars, 1.3, 1.8);
const q4 = (x: number) => Math.round(x * 4) / 4;
const summaryOf = (p: Props) => [p.when.big, p.where?.big].filter((x) => x && x.trim()).map((x) => x!.trim()).join(' · ');
const makePlan = (p: Props) => {
const sc: Scene[] = [];
const add = (key: string, label: string, items: Item[], extra: Partial<Scene> = {}) => { if (items.some((i) => i.role !== 'label')) sc.push({key, label, items, tIn: 0, step: 0, settle: 0, X: 0, hold: 0, chars: 0, ...extra}); };
const it = (f: string, s: string | null | undefined, role: Item['role'], w: number, min: number, accent = false) => lines(s, min).map((text) => ({f, text, role, w, accent}));
const people = p.what.people ?? undefined, seats = p.join?.seats ?? undefined;
add('what', '무엇', [{f: 'brand', text: p.brand.trim(), role: 'label', w: 600}, ...it('what.line', p.what.line, 'serif', 500, 8), ...it('what.big', p.what.big, 'serif', 500, 8, true)], {people});
add('when', p.where?.big || p.where?.line ? '언제·어디' : '언제', [...it('when.big', p.when.big, 'big', 400, 15), ...it('where.big', p.where?.big, 'big', 800, 8), ...it('when.line', p.when.line, 'sub', 500, 19), ...it('where.line', p.where?.line, 'sub', 500, 19)]);
if (p.join && (p.join.big || p.join.line)) {
const jb = p.join.big ? lines(p.join.big, 8) : [];
const big: Item[] = jb.length ? jb.map((text, i) => ({f: 'join.big', text, role: 'big' as const, w: jb.length > 1 && i === 0 ? 400 : 800, accent: jb.length > 1 && i > 0})) : it('join.line', p.join.line, 'big', 800, 8);
add('join', '참여', [...big, ...(jb.length ? it('join.line', p.join.line, 'sub', 500, 19) : [])], {seats});
}
let prevX = 0;
sc.forEach((s, i) => {
s.tIn = i === 0 ? 0.3 : prevX + GAP;
const n = s.items.filter((x) => x.role !== 'label').length;
s.step = Math.min(0.2, 0.9 / Math.max(1, n - 1));
s.chars = s.items.reduce((a, x) => a + nch(x.text), 0);
let settle = s.tIn + Math.max(i === 0 ? 1.45 : 1.1, s.step * (n - 1) + 0.45);
if (s.people) settle = Math.max(settle, s.tIn + 0.2 + Math.min(0.5, s.people * 0.033) + 0.35);
if (s.seats) settle = Math.max(settle, s.tIn + 0.4 + Math.min(0.5, s.seats * 0.02) + 0.35);
s.hold = holdFor(s.chars); s.X = q4(settle + s.hold); s.settle = settle; prevX = s.X;
});
const F = prevX, closeSettle = F + 2.5, endHold = clamp(0.9 + 0.03 * nch(p.brand + summaryOf(p)), 1.5, 2.0), end = q4(closeSettle + endHold);
return {scenes: sc, close: {F, settle: closeSettle, end}, end};
};
type Plan = ReturnType<typeof makePlan>;
const lum = (h: string) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; const c = hexRgb(h); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
const contrast = (a: string, b: string) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
const palette = (c: Props['colors']) => {
const [dark, light] = lum(c.ink) <= lum(c.paper) ? [c.ink, c.paper] : [c.paper, c.ink];
const inverted = lum(dark) > 0.2;
let bg = inverted ? c.paper : dark, fg = inverted ? mix(dark, '#000000', 0.9) : light;
for (let k = 0.1; contrast(bg, fg) < 7 && k <= 1; k += 0.1) fg = mix(fg, inverted ? '#000000' : '#ffffff', k);
let acc = c.accent;
for (let k = 0.08; contrast(bg, acc) < 3 && k <= 1; k += 0.08) acc = mix(c.accent, fg, k);
let sub = mix(fg, bg, 0.28);
for (let k = 0.26; contrast(bg, sub) < 4.6 && k >= 0; k -= 0.02) sub = mix(fg, bg, k);
return {bg, fg, sub, accent: c.accent, accentText: acc, inverted, accentFlat: contrast(bg, c.accent) < 1.25};
};
let fontsDone: Promise<void> | null = null;
const loadFonts = (specs: [string, string][]) => (fontsDone ??= (async () => {
for (const href of FONT_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) { await document.fonts.load(s, t); if (!document.fonts.check(s, t)) throw new Error('폰트 없음: ' + s); }
await document.fonts.ready;
})());
let ctx: CanvasRenderingContext2D | null = null;
const measure = (text: string, font: string, track = 0) => {
ctx ??= document.createElement('canvas').getContext('2d')!; ctx.font = font; (ctx as any).letterSpacing = `${track}px`;
const m = ctx.measureText(text);
return {adv: m.width, l: m.actualBoundingBoxLeft, r: m.actualBoundingBoxRight, asc: m.actualBoundingBoxAscent, desc: m.actualBoundingBoxDescent};
};
const fit = (ls: string[], font: (px: number) => string, cap: number) => { if (!ls.length) return cap; const w = Math.max(...ls.map((l) => { const m = measure(l, font(100)); return m.l + m.r; })); return Math.min(cap, Math.floor(100 * MAXW / w)); };
const counterIssues = (text: string, font: (px: number) => string) => {
const q = 300, c = document.createElement('canvas'), out: string[] = []; c.width = c.height = 480;
const g = c.getContext('2d', {willReadFrequently: true})!;
for (const ch of new Set([...text].filter((x) => /[가-힣]/.test(x)))) {
g.clearRect(0, 0, 480, 480); g.font = font(q); g.fillText(ch, 60, 360);
const d = g.getImageData(0, 0, 480, 480).data, on = (x: number, y: number) => d[(y * 480 + x) * 4 + 3] > 127;
let worst = 0;
for (const vert of [true, false]) {
let run = 0, best = 0;
for (let a = 0; a < 480; a++) {
let last = -1, thin = false;
for (let b = 0; b < 480; b++) if (vert ? on(a, b) : on(b, a)) { const gap = b - last - 1; if (last >= 0 && gap >= 1 && gap < 0.02 * q) thin = true; last = b; }
run = thin ? run + 1 : 0; best = Math.max(best, run);
}
worst = Math.max(worst, best / q);
}
if (worst > 0.15) out.push(`"${ch}" 속공간이 실틈으로 막힘(${Math.round(worst * 100)}%)`);
}
return out;
};
type Pos = {it: Item; font: string; px: number; top: number; fill: string};
const layout = (p: Props, plan: Plan, pal: ReturnType<typeof palette>) => plan.scenes.map((s) => {
const pos: Pos[] = [], fillOf = (x: Item) => (x.accent ? pal.accentText : x.role === 'sub' ? pal.sub : pal.fg);
let row: {y: number; n: number; kind: 'people' | 'seats'} | null = null;
if (s.key === 'what') {
const ls = s.items.filter((x) => x.role === 'serif'), px = fit(ls.map((x) => x.text), (z) => SERIF(500, z), 150), pitch = px * 1.2;
const lab = Math.min(40, fit([p.brand], (z) => SANS(600, z), 40)), rowH = s.people ? 150 : 0, gapR = s.people ? 96 : 0;
const blockH = lab + 64 + pitch * (ls.length - 1) + px * 0.95 + gapR + rowH, top = MID - blockH / 2;
pos.push({it: s.items[0], font: SANS(600, lab), px: lab, top, fill: pal.fg});
ls.forEach((x, i) => pos.push({it: x, font: SERIF(500, px), px, top: top + lab + 64 + pitch * i, fill: fillOf(x)}));
if (s.people) row = {y: top + lab + 64 + pitch * (ls.length - 1) + px * 0.95 + gapR + rowH / 2, n: s.people, kind: 'people'};
} else {
const bg = s.items.filter((x) => x.role === 'big'), sb = s.items.filter((x) => x.role === 'sub');
const px = fit(bg.map((x) => x.text), (z) => SERIF(800, z), 170), pitch = px * 1.2;
const ss = fit(sb.map((x) => x.text), (z) => SANS(500, z), 46), sp = ss * 1.5;
const n = Math.max(0, s.seats ?? 0), gh = n ? Math.ceil(n / 10) * 34 - 10 : 0;
const blockH = pitch * (bg.length - 1) + px * 0.95 + (sb.length ? 80 + sp * (sb.length - 1) + ss : 0) + (n ? 72 + gh : 0), top = MID - blockH / 2;
bg.forEach((x, i) => pos.push({it: x, font: SERIF(x.w, px), px, top: top + pitch * i, fill: fillOf(x)}));
const st = top + pitch * (bg.length - 1) + px * 0.95 + 80;
sb.forEach((x, i) => pos.push({it: x, font: SANS(500, ss), px: ss, top: st + sp * i, fill: fillOf(x)}));
if (n) row = {y: top + blockH - gh, n, kind: 'seats'};
}
return {s, pos, row};
});
type Reg = {t: {k: string; f: string; text: string; px: number; box: number[]; op: number; fill: string; role: string}[]; g: {k: string; box: number[]; op: number}[]};
const Main: React.FC<Props> = (p) => {
const frame = useCurrentFrame(), {fps} = useVideoConfig();
const plan = useMemo(() => makePlan(p), [p]);
const pal = useMemo(() => palette(p.colors), [p]);
const [ok, setOk] = useState(false), [h] = useState(() => delayRender('fonts'));
useEffect(() => {
const all = plan.scenes.flatMap((s) => s.items.map((x) => x.text)).join('') + p.brand + summaryOf(p) + '0123456789';
loadFonts([[SERIF(400, 100), all], [SERIF(500, 100), all], [SERIF(800, 100), all], [SANS(500, 40), all], [SANS(600, 40), all], [SANS(700, 40), all]]).then(() => { setOk(true); continueRender(h); }).catch((e) => cancelRender(e));
}, []);
const L = useMemo(() => (ok ? layout(p, plan, pal) : null), [ok]);
if (!ok || !L) return null;
const {bg: BG, fg: FG, sub: SUB, accent: ACC} = pal;
const draw = (t: number, reg?: Reg) => {
const els: React.ReactNode[] = [];
const txt = (key: string, ps: Pos, op: number, dy: number, dx = 0) => {
if (op <= 0.001) return;
const m = measure(ps.it.text, ps.font, ps.it.role === 'label' ? 6 : 0), x0 = W / 2 - m.adv / 2 + dx, y = ps.top + m.asc + dy;
reg?.t.push({k: key, f: ps.it.f, text: ps.it.text, px: ps.px, box: [x0 - m.l, y - m.asc, x0 + m.r, y + m.desc].map(Math.round), op: +op.toFixed(3), fill: ps.fill, role: ps.it.role});
els.push(<text key={key} x={x0} y={y} style={{font: ps.font, letterSpacing: ps.it.role === 'label' ? 6 : 0}} fill={ps.fill} opacity={op}>{ps.it.text}</text>);
};
const gfx = (key: string, box: number[], op: number) => { if (op > 0.001) reg?.g.push({k: key, box: box.map(Math.round), op: +op.toFixed(3)}); };
L.forEach(({s, pos, row}, si) => {
if (t < s.tIn - 1.2 || t > s.X + 1.0) return;
const D = si === 0 ? D1 : D2, dy = scene(t, s.tIn, s.X, D);
if (s.key === 'what') {
txt(`${si}-lab`, pos[0], ramp(t, 0.03, 0.73), inOff(t, 0.2, 646) + outOff(t, s.X - 0.07, 1.3 * matchA(D, GAP)));
pos.slice(1).forEach((ps, i) => txt(`${si}-${i}`, ps, ramp(t, s.tIn - 0.143 + 0.233 * i, s.tIn + 0.087 + 0.233 * i), dy));
} else {
pos.forEach((ps, i) => txt(`${si}-${i}`, ps, ramp(t, s.tIn - 0.1 + s.step * i, s.tIn + 0.2 + s.step * i), dy));
}
if (row?.kind === 'people') {
const n = row.n, cols = Math.min(5, n), rows = Math.ceil(n / cols), dd = 24, gp = 14, gw = cols * dd + (cols - 1) * gp, gh = rows * dd + (rows - 1) * gp;
const t0 = s.tIn + 0.167, dx = inOff(t, t0, 400, tauOf(0.84)), rDy = outOff(t, s.X, matchA(D, GAP)), op = ramp(t, t0 - 0.05, t0 + 0.03), stg = Math.min(0.033, 0.5 / n);
const gx = W / 2 - 40 - gw - dx, gy = row.y - gh / 2 + rDy;
gfx(`${si}-dots`, [gx, gy, gx + gw, gy + gh], op);
els.push(<g key={`${si}-dots`} transform={`translate(${gx},${gy})`} opacity={op}>
{Array.from({length: n}, (_, i) => { const k = spring(t, t0 + 0.033 + i * stg, 4, 0.55); return <circle key={i} cx={(i % cols) * (dd + gp) + dd / 2} cy={Math.floor(i / cols) * (dd + gp) + dd / 2} r={Math.max(0, dd / 2 * k)} fill={ACC} />; })}
</g>);
const bx = W / 2 + 40 + 75 + dx, by = row.y + rDy;
gfx(`${si}-badge`, [bx - 74, by - 74, bx + 74, by + 74], op);
els.push(<g key={`${si}-badge`} transform={`translate(${bx},${by})`} opacity={op}>
<circle r={72} fill="none" stroke={FG} strokeWidth={4} /><circle r={58} fill="none" stroke={FG} strokeWidth={2} strokeDasharray="3 7" />
<text y={19} textAnchor="middle" style={{font: SERIF(800, 54)}} fill={FG}>{n}</text>
</g>);
}
if (row?.kind === 'seats') {
const n = row.n, cols = Math.min(10, n), sz = 24, gp = 10, gw = cols * (sz + gp) - gp, rows = Math.ceil(n / cols);
const t0 = s.tIn + 0.35, dx = -inOff(t, t0, 360, tauOf(0.84)), op = ramp(t, t0 - 0.05, t0 + 0.05), stg = Math.min(0.02, 0.5 / n);
const gx = W / 2 - gw / 2 + dx, gy = row.y + dy;
gfx(`${si}-seats`, [gx, gy, gx + gw, gy + rows * (sz + gp) - gp], op);
els.push(<g key={`${si}-seats`} transform={`translate(${gx},${gy})`} opacity={op}>
{Array.from({length: n}, (_, i) => { const k = spring(t, t0 + 0.05 + i * stg, 4, 0.55); const c = sz * Math.max(0, k); return <rect key={i} x={(i % cols) * (sz + gp) + (sz - c) / 2} y={Math.floor(i / cols) * (sz + gp) + (sz - c) / 2} width={c} height={c} rx={3} fill="none" stroke={SUB} strokeWidth={3} />; })}
</g>);
}
});
const {F} = plan.close, cx = W / 2, cy = 1010;
if (t >= F && t < F + 1.25) {
const r = 1250 * bez(0.65, 0, 0.35, 1)(ramp(t, F, F + 0.3)), k = spring(t, F + 0.75, 2.6, 0.9);
if (k <= 0) { els.push(<circle key="flood" cx={cx} cy={cy} r={r} fill={ACC} />); gfx('flood', [cx - r, cy - r, cx + r, cy + r], 1); }
else { const b = [lerp(0, 97, k), lerp(0, 950, k), lerp(W, 886, k), lerp(H, 120, k)]; els.push(<rect key="band" x={b[0]} y={b[1]} width={b[2]} height={b[3]} rx={40 * Math.sin(Math.PI * Math.min(1, k))} fill={ACC} />); gfx('band', [b[0], b[1], b[0] + b[2], b[1] + b[3]], 1); }
}
if (t >= F + 1.25) {
const sp = spring(t, F + 1.25, 3, 0.8), M = bez(0.55, 0, 0.25, 1)(ramp(t, F + 1.5, F + 2.25)), tint = ramp(t, F + 1.25, F + 1.5), back = ramp(t, F + 1.6, F + 2.35);
const TINT = [ACC, mix(ACC, pal.inverted ? BG : FG, 0.4), mix(ACC, pal.inverted ? BG : FG, 0.72)], mk = p.mark ?? 'arc';
const target = (i: number) => (u: number): number[] => {
if (mk === 'bars') { const hh = [300, 220, 140][i], x = cx + (i - 1) * 150; return [x, cy - hh * u]; }
if (mk === 'ring') { const R = [210, 150, 90][i], th = Math.PI / 2 + 2 * Math.PI * u; return [cx + R * Math.cos(th), cy - 210 + R * Math.sin(th)]; }
const R = [300, 215, 130][i], th = Math.PI * (1 - u); return [cx + R * Math.cos(th), cy - R * Math.sin(th)];
};
[0, 1, 2].forEach((i) => {
const y = lerp(970 + 40 * i, [960, 1010, 1060][i], sp);
els.push(<path key={'ln' + i} d={morphPath(80, (u) => [lerp(97, 983, u), y], target(i), M, 0.15)} fill="none" stroke={mix(ACC, TINT[i], tint * (1 - back))} strokeWidth={lerp(lerp(40, 20, sp), mk === 'arc' ? 46 : 40, M)} strokeLinecap={mk === 'arc' ? 'butt' : 'round'} />);
});
gfx('mark', M > 0.98 ? (mk === 'arc' ? [cx - 323, 687, cx + 323, cy] : mk === 'bars' ? [cx - 170, cy - 320, cx + 170, cy + 20] : [cx - 230, cy - 440, cx + 230, cy + 20]) : [77, 920, 1003, 1100], 1);
const bp: Pos = {it: {f: 'brand', text: p.brand.trim(), role: 'label', w: 700}, font: SANS(700, Math.min(56, fit([p.brand], (z) => SANS(700, z), 56))), px: 56, top: 1062, fill: FG};
const by = inOff(t, F + 1.45, 468, tauOf(0.87)), bo = ramp(t, F + 1.55, F + 1.65);
if (bo > 0) { const m = measure(bp.it.text, bp.font); const x0 = W / 2 - m.adv / 2, y = 1062 + m.asc + by; reg?.t.push({k: 'end-brand', f: 'brand', text: bp.it.text, px: 56, box: [x0 - m.l, y - m.asc, x0 + m.r, y + m.desc].map(Math.round), op: +bo.toFixed(3), fill: FG, role: 'label'}); els.push(<text key="brand" x={x0} y={y} style={{font: bp.font}} fill={FG} opacity={bo}>{bp.it.text}</text>); }
const sum = summaryOf(p);
if (sum) txt('end-sum', {it: {f: 'summary', text: sum, role: 'sub', w: 500}, font: SANS(500, Math.min(40, fit([sum], (z) => SANS(500, z), 40))), px: 40, top: 1156, fill: SUB}, ramp(t, F + 1.8, F + 2.1), inOff(t, F + 1.75, 200));
}
return els;
};
const t = frame / fps, N = Math.max(1, Math.round(p.sub ?? 4));
const layers = Array.from({length: N}, (_, k) => <svg key={k} width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{position: 'absolute', inset: 0, opacity: 1 / (k + 1)}}><defs><linearGradient id={'bg' + k}><stop offset="0" stopColor={BG} /><stop offset="1" stopColor={BG} /></linearGradient></defs><rect width={W} height={H} fill={`url(#bg${k})`} />{draw(t + (k - (N - 1) / 2) / (fps * N))}</svg>);
const chk = checkFrames(plan, fps).find((c) => c.frame === frame);
if (chk) runCheck(p, plan, L, pal, draw, t, chk);
return (
<AbsoluteFill style={{background: BG}}>
{layers}
{p.texture ? <Img src={staticFile(p.texture)} style={{position: 'absolute', inset: 0, width: W, height: H, objectFit: 'cover', mixBlendMode: 'soft-light', opacity: 0.35}} /> : null}
</AbsoluteFill>
);
};
const checkFrames = (plan: Plan, fps: number) => {
const out: {frame: number; scene: number; at: string}[] = [];
const add = (scene: number, a: number, b: number) => (['start', 'mid', 'end'] as const).forEach((at, i) => out.push({frame: Math.round([a + 0.05, (a + b) / 2, b - 0.05][i] * fps), scene, at}));
plan.scenes.forEach((s, i) => add(i + 1, s.settle, s.X));
add(plan.scenes.length + 1, plan.close.settle, plan.close.end);
return out;
};
const runCheck = (p: Props, plan: Plan, L: ReturnType<typeof layout>, pal: ReturnType<typeof palette>, draw: (t: number, reg?: Reg) => React.ReactNode[], t: number, c: {frame: number; scene: number; at: string}) => {
const reg: Reg = {t: [], g: []}; draw(t, reg);
const over: string[] = [], hint: string[] = [], vis = reg.t.filter((x) => x.op > 0.05);
for (const x of vis) {
const [l, tp, r, b] = x.box;
if (tp < SAFE_T - 1 || b > SAFE_B + 1 || l < SIDE - 2 || r > W - SIDE + 2) { over.push('safe-area'); hint.push(`"${x.text}" 잉크 ${x.box.join(',')} 가 안전 영역(위 ${SAFE_T}·아래 ${SAFE_B}·좌우 ${SIDE}) 밖`); }
const need = x.px >= 60 ? 3 : 4.5, cr = contrast(x.fill, pal.bg);
if (cr + 1e-6 < need) { over.push('contrast'); hint.push(`"${x.text}" 대비 ${cr.toFixed(2)} < ${need} — 브랜드 색을 바꿀지 사용자에게`); }
if ((x.role === 'serif' || x.role === 'big') && x.px < MIN_BIG) { over.push('big-small'); hint.push(`"${x.text}" ${x.px}px < ${MIN_BIG}px — 글자가 길다. 줄인 안을 사용자에게`); }
}
const hitBox = (a: number[], b: number[], m: number) => Math.min(a[2], b[2]) + m - Math.max(a[0], b[0]) > 2 && Math.min(a[3], b[3]) + m - Math.max(a[1], b[1]) > 2;
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) if (hitBox(vis[i].box, vis[j].box, 0)) { over.push('text-collide'); hint.push(`"${vis[i].text}" 과 "${vis[j].text}" 잉크가 겹침(정지 화면)`); }
for (const x of vis) for (const g of reg.g) if (g.op > 0.05 && hitBox(x.box, g.box, 8)) { over.push('gfx-collide'); hint.push(`"${x.text}" 에 그래픽 ${g.k} 가 걸림(정지 화면)`); }
const sc = plan.scenes[c.scene - 1];
if (c.at === 'mid' && sc) {
const want: Record<string, string> = {'brand': p.brand, 'what.big': p.what.big ?? '', 'what.line': p.what.line ?? '', 'when.big': p.when.big ?? '', 'when.line': p.when.line ?? '', 'where.big': p.where?.big ?? '', 'where.line': p.where?.line ?? '', 'join.big': p.join?.big ?? '', 'join.line': p.join?.line ?? ''};
for (const f of [...new Set(sc.items.map((x) => x.f))]) {
const got = vis.filter((x) => x.f === f && x.op > 0.95 && !x.k.startsWith('end')).map((x) => x.text).join(' ');
if (norm(got) !== norm(want[f])) { over.push('text-mismatch'); hint.push(`${f}: 화면 "${got}" ≠ 입력 "${want[f]}"`); }
}
for (const x of vis.filter((y) => y.role === 'serif' || y.role === 'big')) for (const s of counterIssues(x.text, (z) => SERIF(800, z))) { over.push('counter'); hint.push(s); }
}
if (c.at === 'mid' && !sc && summaryOf(p) && !vis.some((x) => x.f === 'summary' && x.op > 0.95)) { over.push('text-mismatch'); hint.push('맺음 요약줄이 안 보임'); }
console.log('DUCHAMP_CHECK ' + JSON.stringify({...c, over: [...new Set(over)], hint, items: vis.map(({text, px, box, op, fill}) => ({text, px, box, op, fill}))}));
};
const DEF: Props = {brand: '볕뜰 상회', colors: {paper: '#ECE8DF', ink: '#161616', accent: '#E0442B'}, what: {big: '벼룩장', line: '이웃 열다섯 집이 물건을 내놓아요', people: 15}, when: {big: '11.08 — 11.09', line: '일·월 낮 12시부터 5시까지'}, where: {big: '연남', line: '마포구 성미산로 17길 3, 앞마당'}, join: {big: '선착순 | 30명', line: '장바구니를 가져오면 꽃 한 송이를 드려요', seats: 30}};
const Root: React.FC = () => (
<Composition id="Main" component={Main as any} width={W} height={H} fps={60} durationInFrames={720} defaultProps={DEF as any}
calculateMetadata={({props}: any) => { const plan = makePlan(props), fps = props.fps ?? 60; return {fps, durationInFrames: Math.round(plan.end * fps), props: {...props, plan, palette: palette(props.colors), checks: checkFrames(plan, fps)}}; }} />
);
registerRoot(Root);
```
`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));
};
```
## 6. 실행
```bash
npm install --no-audit --no-fund
node render.mjs plan # 검사 + out/beats.md(비트맵) + out/still-<n>.png(장면마다 정지 가운데) + out/contact.png(비트마다 1장) (약 20초)
node render.mjs mp4 # 위 전부 + out/video.mp4(60fps · 서브프레임 4장 · 무음) + 움직임 프로파일 검사 (약 30초~1분)
```
- 게이트(§2e): `출력: 계획 먼저` 일 때만 plan 결과를 보여 승인받은 뒤 mp4. 기본(`MP4`)은 묻지 않고 바로 mp4 까지.
- **종료 코드 2 = 검사 실패 → 거기서 멈춘다.** `out/check.json` 의 `overflow`·`hint` 를 읽고 원인(글자 길이·색·수)을 고친 뒤 다시 실행한다. 글자를 줄이거나 바꿔야 하면 마음대로 고치지 말고 사용자에게 고친 안을 물어본다.
- 폰트를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
## 7. 질감 판 (선택 — 기본은 `없음`)
이 영상은 판 없이 코드만으로 완성된다(레퍼런스처럼 평평한 단색 바탕). `질감 판` 을 쓰면 종이 사진을 화면 전체에 소프트라이트 35% 로 얹어 바탕에 아주 옅은 명암만 더한다(움직이지 않는다).
- **없음**(기본): 아무것도 하지 않는다. 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.<확장자>"` 를 더한다.
## 8. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: `ok: true`, `overflow: []`. `overflow` 뜻: `input`(빈칸·색 형식)·`count-range`(인원 2~30·정원 2~60 밖)·`safe-area`·`contrast`(색 조합 → 사용자에게)·`accent-flat`·`big-small`(글자가 길다 → 줄인 안을 묻는다)·`text-mismatch`·`text-collide`·`gfx-collide`·`counter`(속공간)·`profile`·`hold`·`pop`·`format`·`audio`(마지막 일곱은 코드 문제 — 그대로 보고). `scenes[]` 에 장면별 정지 화면 글자 상자, `profile` 에 멈춤·느린 흐름·큰 움직임 %·장면별 정지 초·튐, `mp4` 에 규격이 남는다.
2. `out/still-1.png` ~ 마지막 장면을 **한 장씩 직접 열어 보고**(이미지 보기 도구) §2(d) 체크리스트 1~3·5 를 확인한다.
3. `out/contact.png` 를 열어 §2(d) 4 를 확인한다.
4. 문제가 있으면 원인(글자·props)을 고쳐 다시 렌더한다.
## 9. 보고
결과 파일 경로(`out/video.mp4`, `out/still-<n>.png`, `out/contact.png`, `out/beats.md`), 게이트를 거쳤는지 건너뛰었는지, 고른 맺음 마크와 이유, 인원·정원을 어디서 받았는지(없으면 없음), check.json 의 프로파일(멈춤/느린/큰 %)·장면별 정지 초·튐, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 그리고 한 줄: **"이 영상은 무음입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 저작권 걱정 없는 음원으로 붙이세요."**
폰트(모두 SIL OFL 1.1, 상업 사용 가능): 큰 글자 Noto Serif KR · 작은 글자 Pretendard.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Codex(권장)나 Claude Code 에 붙여 넣고 브랜드·색·컷별 글자를 알려 주세요. 이미지 생성 없이 코드만으로 렌더해서 어느 쪽이든 그대로 됩니다.
- 올리기
MP4 파일을 받아 릴스·쇼츠로 올리세요. 음악은 편집 앱에서 붙이세요.
컷 구성
- 컷 1 · 3.85초 · 무엇
- 작은 브랜드명 아래로 한 줄과 행사 이름이 올라와 멈추고, 인원만큼 점과 숫자 배지가 좌우에서 모인다
- 컷 2 · 3.5초 · 언제·어디
- 앞 장면이 위로 빠지는 속도 그대로 날짜·장소가 밀고 올라온다 — 장소가 없으면 날짜만
- 컷 3 · 2.65초 · 참여
- 참여 조건이 보통·굵게로 서고, 정원만큼 빈 자리 칸이 옆에서 들어온다 — 참여 칸이 없으면 이 장면을 뺀다
- 컷 4 · 4초 · 맺음
- 포인트색 면이 화면을 채웠다가 띠 → 세 줄 → 호·고리·막대 중 하나로 휘고, 브랜드명과 날짜·장소 요약줄로 끝난다
예시 영상의 컷입니다. 레시피는 장면 3~4개 · 약 12~15초 안에서 내가 준 항목 수에 맞춰 컷을 나눕니다.
지킬 것
- 잘 맞는 때: 장터·북토크·모임처럼 날짜와 장소가 정해진 행사를 알릴 때
- 날짜·시간·장소·혜택은 내가 알려 준 값만 씁니다 — 주지 않은 칸은 비워 두고 지어 넣지 않게 돼 있어요.
- 점·자리 칸 수는 인원·정원 칸에 숫자로 적어 준 것만 씁니다. 문장 속 숫자로 짐작하지 않아요.
- 60fps 무음 MP4 입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 붙이세요.


