← 캐러셀
캐러셀 · 사진·3D
매크로 질감
코드 렌더 · 같은 결과
아주 가까이 찍은 질감 위에 굵은 글자를 올리세요.
1장1 / 5
가상 브랜드 느린 로스터리로 레시피를 실제 실행해 렌더한 5장입니다. 번호나 화살표로 넘겨 보세요.
레시피 보기84,876자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 매크로 질감 캐러셀 5장 (재료를 아주 가까이 찍은 질감 + 굵은 문구)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델에게 한글을 그리게 하지 않는다. 글자 없는 재료 매크로 사진 판 4~5장(a·b·c·d + 선택 e — **장마다 다른 장면**) 위에 실제 폰트로 글자를 코드로 얹고, 헤드리스 브라우저로 5장(PNG, 필요하면 장별 MP4)을 렌더한다. 다섯 장은 한 컴포지션이라 서체·크기·여백·색이 장마다 같다.
- 1장(표지): 화면을 꽉 채운 대표 재료 질감 + 아래쪽의 아주 굵은 **제목 한 덩어리만**(부제 없음, 보통 폭 70% 이상) + 포인트색 막대, 위에 장 번호·브랜드
- 2~4장: 장마다 그 장 글자의 장면(같은 재료의 다른 상태·도구가 닿은 결·단면 등) + 아래 45% 에 막대·소제목(세 장 같은 크기)·본문 1~3줄. 위 55% 는 질감만
- 5장: 마무리 장면(판 e, 없으면 표지 판 a)을 초점 밖으로 흐려 깔고(매크로의 얕은 심도처럼) 가운데 아래에 마무리 문장(한 줄/두 줄은 코드가 고른다 — 두 줄도 정상) + 브랜드
- 장마다 판 하나(1 = a, 2 = b, 3 = c, 4 = d, 5 = e 1.1배 + 흐림). **좌우 뒤집기·같은 판 다시 쓰기는 하지 않는다**(다시 쓰는 판은 1·5장뿐 — e 가 없으면 5장 = a 1.15배 + 흐림). 코드가 밝기 레벨과 색조 맵으로 다섯 장의 색을 브랜드 색조 쪽으로 당긴다(판이 이미 같은 색 계열이면 판 색을 거의 그대로 둔다). **보정 강도 규칙**: 판과 색조의 밝기 차가 0.3 을 넘으면(밝은 판 + 어두운 색조 등) 밝기는 곱하기로 0.15 까지만 옮기고 색만 "그 밝기의 색조"로 0.3 물들인다(밝은 판을 어둡게 끌어내리면 회갈색으로 물든다). 모자란 글자 대비는 그늘(최대 0.7)이 맡는다
- 글자 자리 검사: 글자 덩어리 밑 질감이 거칠면(`zone-busy`) 실패 → 그 판 다시. 글자 대비가 모자라면 그 자리에만 위아래로 번지는 색조 그늘을 깐다(상자·판 없음, 최대 0.55)
원본 템플릿: https://duchamp.app/readymade/carousels/macro-texture
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "색조, 글자색, 포인트색" HEX 3개. 색조 = 질감 사진의 전체 색(밝은 색이면 글자는 어둡게, 어두운 색이면 글자는 밝게). 비우면 #2A1D16, #FFF6EA, #F2B134
주제: (내 브랜드에 맞는 주제 하나) # 어떤 재료·과정인지. 예: 비누 한 장이 만들어지는 과정
넣을 글자: (주제에 맞춰 분량 상한 안에서 장별 문구를 지어 넣는다) # 장마다 한 줄씩, 한 줄 안은 " / " 로 나눈다
# 1장: 제목 10자 이내(줄을 직접 정하려면 "비누 한 장의 | 시간")
# 2장: 소제목 / 본문 줄 1~3개 소제목 8자, 본문 줄마다 18자 이내
# 3장: 소제목 / 본문 줄 1~3개
# 4장: 소제목 / 본문 줄 1~3개
# 5장: 마무리 문장 하나 10자 이내(화면에서는 코드가 두 줄로 나눌 수 있다)
출력: PNG만 # PNG만 | MP4까지
배경판: 자동 # 자동 | 내 사진: <a>, <b>, <c>, <d>[, <e>] | 예시 판 | ChatGPT 판: <a>, <b>, <c>, <d>[, <e>]
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 글자 한도·줄 수를 넘으면 줄여 달라고 한다. 브랜드명·넣을 글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 브랜드명은 화면에 쓸 이름 그대로 한 번만 넣는다. 장 번호(01~05)는 코드가 붙인다. 성분·효능·수치 등 주지 않은 사실은 만들지 않는다.
- 질감 사진은 분위기 연출이다. 실제 제품·성분 사진처럼 소개하지 않는다.
- **2~4장에는 브랜드명을 쓰지 않는다.** 2~4장 글자에 브랜드명이 들어 있으면(띄어쓰기 무시) 코드 검사가 `brand-2to4` 로 실패한다. 그때는 뺀 안을 사용자에게 물어본다. 브랜드는 1장과 5장에만 나온다.
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.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/a.(png|jpg) ← 판 a: 대표 재료(1장 제목의 결과물 표면 등) → 1장 (e 가 없으면 5장도 — 흐림)
public/plates/b.(png|jpg) ← 판 b: 2장 글자의 장면 → 2장
public/plates/c.(png|jpg) ← 판 c: 3장 글자의 장면 → 3장
public/plates/d.(png|jpg) ← 판 d: 4장 글자의 장면 → 4장
public/plates/e.(png|jpg) ← 판 e(선택): 5장 마무리 장면 → 5장(1.1배 + 흐림)
out/s1.png … out/s5.png out/check.json (out/s1.mp4 … out/s5.mp4 out/frames/*.png)
```
장마다 판 하나다. **판을 좌우로 뒤집거나 다른 장의 판을 잘라 다시 쓰지 않는다**(같은 결이 두 번 보이면 바로 티가 난다). 다시 쓰는 판은 1·5장뿐이다. b·c·d 중 없는 것이 있으면 a 를 다른 자리로 잘라 채우고 `warn: plate-reuse` 를 남긴다(보고에 쓴다).
## 3. 배경판 고르기 (`배경판` 값에 따라 하나)
판 조건(다섯 장 공통): 100mm 매크로 1:1 로 아주 가까이 찍은 재료 질감, 4:5 세로, 질감이 화면을 끝까지 채움(수평선·배경·용기 가장자리·물건 전체 모습 없음), 낮은 각도의 부드러운 옆빛(다섯 장 같은 방향). **아래 45% 는 가장 고르고 매끈한 결**(글자 자리), 볼거리는 위쪽 절반. **물방울·구슬·거품·완벽한 구·번들거리는 젖은 광택·반짝이 없음**(생성 사진 티), 지저분한 부스러기·얼룩 없음, 글자·숫자·로고 없음, HDR·판 그레인 없음.
**장면은 장 글자에서 고른다 — 장마다 다른 장면**: 같은 재료의 다른 상태(날것 → 녹인 것 → 굳은 것), 도구가 닿은 결(칼집·주걱 자국·체에 거른 결), 단면(자른 면·켜), 곁들인 재료 등. 1장 = 결과물 표면(제목이 말하는 것), 2~4장 = 그 장 소제목·본문의 재료·단계, 5장 = 마무리 장면(완성품 표면을 다른 자리에서, 또는 마무리 문장의 것). 다섯 장의 색이 한 덩어리로만 보이지 않게 장면마다 결의 크기·모양이 달라야 한다(같은 색조 안에서).
- **자동**: 이미지 생성 도구가 있으면(예: Codex 내장 image_gen) 아래 프롬프트로 a 를 먼저 생성하고, b·c·d·e 는 **a 를 참고 이미지로 주고** 생성(같은 빛·같은 색조, 재료·장면만 바뀜) → 생성된 원본 파일을 `cp` 로 `public/plates/a.png`~`e.png` 에 복사(편집·리샘플 금지). 판을 직접 열어 물방울·거품·글자가 있거나, 다른 장 판과 거의 같은 장면이면 그 판만 다시(최대 3번). 렌더에서 `zone-busy`·`contrast` 로 걸린 장이 있으면 그 장의 판만 다시 생성한다. 이미지 생성 도구가 없으면(Claude Code 등) 터미널에서 다른 생성 경로를 이 순서로 확인해 처음 되는 것으로 같은 프롬프트·같은 참고 이미지로 판을 만든다 — ① `codex` CLI(`command -v codex`): `codex exec --skip-git-repo-check -s workspace-write -C . "내장 이미지 생성으로 이 그림을 한 장 만든다: <판 프롬프트>. 생성 원본을 편집 없이 <저장 경로> 로 복사" -i <참고 이미지>` (`-i` 는 맨 뒤에 — 앞에 두면 프롬프트까지 파일로 읽는다. 참고 이미지가 없으면 뺀다) ② `grok` CLI(`command -v grok`): `grok -p "Generate an image: <판 프롬프트>. Use <참고 이미지 경로> as the reference. Save it to <저장 경로>" --always-approve --max-turns 10` (해상도가 낮게 나올 수 있어 ①이 없을 때만) ③ 연결된 이미지 생성 MCP 도구(Higgsfield `generate_image` 등) — 사용자 크레딧이 드니 쓰기 전에 몇 장 만들지 말하고 승인받는다. 판 대조·렌더 검사·다시 만들기 규칙은 어느 경로든 같고, 어느 경로로 판을 만들었는지 보고에 쓴다. 셋 다 없으면 `예시 판` 으로 넘어가고 보고에 그 사실을 쓴다.
- **내 사진: <a>, <b>, <c>, <d>[, <e>]** / **ChatGPT 판: …**: 차례로 `public/plates/a.<확장자>` ~ `e` 로 복사. 없는 것은 null.
- **예시 판**: Duchamp 공개 예시 판 5장을 받는다. **장 글자와 무관한 중립 질감**이다(a 손으로 뜬 코튼지 · b 매끈한 백자토 · c 석회 미장 · d 고운 모래 물결 · e 밝은 물푸레나무 결, 모두 밝은 미색 — 어떤 재료 이야기에도 틀린 사물을 보여 주지 않도록 고른 것). 색조가 어두우면 보정 강도 규칙으로 따뜻한 갈색이 되고 그늘이 진해진다. 글자에 맞는 장면이 필요하면 자동·내 사진으로 바꾸라고 보고에 쓴다.
```bash
mkdir -p public/plates
for f in a.jpg b.jpg c.jpg d.jpg e.jpg; do curl -fsSL https://duchamp.app/readymade/recipe-plates/macro-texture/$f -o public/plates/$f; done
```
판 공통 꼬리(다섯 프롬프트 끝에 붙인다; `{주제}`·`{색조}` 만 바꿔 넣는다):
```
Portrait 4:5 extreme macro photograph about {주제}, one of a set of five with the same light direction and the same overall colour tone close to {색조}. 100mm macro lens at 1:1, focus stacked so the texture is sharp edge to edge. The texture fills the whole frame: no horizon, no background, no recognizable whole object, no edges of a container. Soft raking light from the upper left at a low angle shows the relief, gentle natural falloff. Calm, satin or matte surfaces that look like an ordinary real macro photo: NO water droplets, NO beads, NO bubbles, NO perfect spheres, NO wet glossy sheen, NO glitter, no messy crumbs or stains, no HDR look, no added grain or noise. The lower 45% of the frame is the calmest, smoothest part of the texture with few details (bold text will be placed there later); the most interesting relief is in the upper half. NO text, letters, numbers, logos or watermarks. Fully opaque, edge to edge.
```
아래 `{…}` 를 넣을 글자에서 골라 영어 짧은 구로 바꿔 넣는다 — 예: 2장 "기름을 녹여요" → `pale golden plant oils being slowly blended, smooth satin swirls`, 3장 "천천히 저어요" → `thick ivory soap batter at trace with spatula ridges`, 4장 "그늘에서 말려요" → `the cut face of a cured soap loaf, fine dry matte grain`.
- 판 a: `The surface of the finished result of {주제} (what 1장 talks about) seen very close, with a few small natural inclusions in the upper half only.` + 꼬리
- 판 b·c·d: `Same light and tone as the reference image, a different scene: {2장(b)·3장(c)·4장(d) 글자의 재료·상태·도구 자국·단면}.` + 꼬리
- 판 e(선택): `Same light and tone as the reference image: {5장 마무리 문장의 장면, 없으면 the finished result from a different spot, calmer and smoother}.` + 꼬리
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"title": "<1장 제목 그대로>",
"bodies": [
{"head": "<2장 소제목>", "lines": ["<본문 줄>", "..."]},
{"head": "<3장 소제목>", "lines": ["..."]},
{"head": "<4장 소제목>", "lines": ["..."]}
],
"last": "<5장 마무리 문장 그대로>",
"colors": {"tone": "<색조>", "ink": "<글자색>", "accent": "<포인트색>"},
"plates": {"a": "plates/a.<확장자>", "b": "plates/b.<확장자>", "c": "plates/c.<확장자>", "d": "plates/d.<확장자>", "e": "plates/e.<확장자>"}
}
```
- 넣을 글자의 장별 줄을 ` / ` 로 나눈 그대로 넣는다(2~4장 첫 조각 = head, 나머지 = lines). 없는 판은 `null`.
- 크기는 코드가 정한다: 제목 최대 240px(두 줄 200px), 자간 −3%, 2~4장 소제목은 세 장 공통 크기(최대 132px), 본문 44px(세 장 같은 배율, 0.8배 아래면 실패), 5장 마무리 문장 최대 176px. 줄은 빈칸에서 코드가 나누고, `|` 를 넣으면 그 자리에서 나눈다(뜻이 끊기는 줄바꿈이면 `|` 로 정한다).
## 5. 코드 (그대로 저장)
`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-sans-kr": "5.3.0"
}
}
```
`render.mjs`
```js
// usage: node render.mjs png | mp4 [장 번호들, 예: 1,3] → out/s1.png … out/s5.png (+ out/s1.mp4 …), out/check.json
import fs from 'node:fs';
import path from 'node:path';
import {spawnSync} from 'node:child_process';
import {ff, meta, pick, render} from './hf.mjs';
const mode = process.argv[2] ?? 'png';
const only = (process.argv[3] ?? '1,2,3,4,5').split(',').map(Number);
const base = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.mkdirSync('out', {recursive: true});
// 판 검사(채용 캐러셀처럼 tools/facecheck.swift 가 있을 때만): 렌더 전에 얼굴·사람을 찾고, 인물 판 머리 둘레를 out/head-zoom.png 로 확대해 둔다
let plates = null;
if (fs.existsSync('tools/facecheck.swift')) {
const list = [['person', base.plate], ['scene', base.detail], ['scene', base.detail2], ['scene', base.detail3]].filter(([, f]) => f).map(([k, f]) => `${k}:public/${f}`);
if (process.platform !== 'darwin') plates = {ok: null, skipped: 'macOS 가 아니라 Vision 검사를 못 함 → 판을 직접 열어 얼굴·귀·볼·수염이 없는지 확인'};
else {
const r = spawnSync('swift', ['tools/facecheck.swift', 'out/head-zoom.png', ...list], {encoding: 'utf8'});
try { plates = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { plates = {ok: false, error: (r.stderr || r.stdout || 'facecheck 실행 실패').slice(-400)}; }
}
console.log('PLATES', JSON.stringify(plates));
if (plates.ok === false) {
fs.writeFileSync('out/check.json', JSON.stringify({ok: false, plates, slides: []}, null, 2));
console.error('판 검사 실패 → 렌더하지 않음. 얼굴·귀·눈·코·사람이 잡힌 판을 다시 만들거나 다른 판으로 바꾼 뒤 다시(오류가 Vision 실패면 샌드박스 밖에서 실행)');
process.exit(2);
}
}
const t0 = Date.now();
const sec = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
const metas = {};
const slides = [];
for (const n of only) {
const inputProps = {...base, slide: n};
metas[n] = await meta(inputProps);
// 완성 프레임 = 42(등장 끝, 분위기 위상 0) → 정지 이미지
const r = await render({name: `s${n}`, props: inputProps, m: metas[n], format: 'png', frames: [42], out: [`out/s${n}.png`]});
if (r.fail) { console.error(`렌더 실패(${n}장):`, r.fail); process.exit(1); }
const c = pick(r.logs, 'DUCHAMP_CHECK').at(-1) ?? {ok: false, error: 'DUCHAMP_CHECK 없음'};
slides.push({slide: n, ...c});
console.log(`PNG out/s${n}.png ${c.ok ? 'OK' : 'FAIL ' + JSON.stringify(c.overflow ?? c.error)} ${sec()}`);
}
const ok = slides.every((s) => s.ok);
fs.writeFileSync('out/check.json', JSON.stringify({ok, ...(plates ? {plates} : {}), slides}, null, 2));
console.log('CHECK', ok ? 'ok' : 'FAIL', '→ out/check.json');
if (!ok) {
console.error('검사 실패 → 여기서 멈춤(MP4 를 만들지 않음). out/check.json 의 장별 overflow·hint 를 보고 원인을 고친 뒤 다시');
process.exit(2);
}
if (mode === 'mp4') {
fs.mkdirSync('out/frames', {recursive: true});
for (const n of only) {
let lastP = -1;
const v = await render({name: `v${n}`, props: {...base, slide: n}, m: metas[n], format: 'mp4', crf: 17, out: `out/s${n}.mp4`,
onProgress: (p) => { const q = Math.floor(p * 4); if (q !== lastP) { lastP = q; console.log(`MP4 s${n} ${q * 25}%`); } }});
if (v.fail) { console.error(`렌더 실패(${n}장):`, v.fail); process.exit(1); }
// 확인용 프레임(영상에서 직접 뽑음, ffmpeg-static) → out/frames/s<n>-t*.png
for (const t of ['0.00', '0.70', '1.40', '5.36']) {
ff(['-v', 'error', '-y', '-ss', t, '-i', path.resolve(`out/s${n}.mp4`), '-frames:v', '1', '-vf', 'scale=540:-2', path.resolve(`out/frames/s${n}-t${t}.png`)]);
}
console.log(`MP4 out/s${n}.mp4 ${sec()} (5.4s · 1080×1350 · 30fps · 등장 1.4s + 분위기 4s)`);
}
}
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, useState} from 'react';
import {AbsoluteFill, Composition, Easing, Img, cancelRender, continueRender, delayRender, interpolate, registerRoot, staticFile, useCurrentFrame} from './duchamp-frame';
const W = 1080, H = 1350, FPS = 30, INTRO = 42, LOOP = 120;
// ── 공통 도구 ──
// 폰트: CSS 를 브라우저 안에서 불러오고, 실제 글자로 확인한다. 못 불러오면 렌더 실패(대체 글꼴로 조용히 그리지 않는다).
const loadFonts = async (css: string[], specs: [string, string][]) => {
for (const href of css) {
await new Promise<void>((res, rej) => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = href;
l.onload = () => res(); l.onerror = () => rej(new Error('폰트 CSS 로드 실패: ' + href));
document.head.appendChild(l);
});
}
for (const [s, t] of specs) {
if (!t) continue;
await document.fonts.load(s, t);
if (!document.fonts.check(s, t)) throw new Error('폰트 없음: ' + s + ' / ' + t);
}
await document.fonts.ready;
};
const loadImage = (src: string) => new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i); i.onerror = () => rej(new Error('이미지 로드 실패: ' + src)); i.src = src;
});
const drawCover = (g: CanvasRenderingContext2D, i: HTMLImageElement) => {
const s = Math.max(W / i.width, H / i.height), dw = i.width * s, dh = i.height * s;
g.drawImage(i, (W - dw) / 2, (H - dh) / 2, dw, dh);
};
const hexRgb = (h: string) => { const n = parseInt(h.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const OUT = Easing.bezier(0.16, 1, 0.3, 1);
const INOUT = Easing.bezier(0.83, 0, 0.17, 1);
const ramp = (f: number, a: number, b: number, ease = OUT) => interpolate(f, [a, b], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ease});
const enter = (f: number, s: number, len: number) => 1 - ramp(f, s, s + len); // 1=숨음 → 0=제자리
const phase = (frame: number) => ((((frame - INTRO) % LOOP) + LOOP) % LOOP) / LOOP; // 분위기 위상(1.4초에서 0)
// 필름 그레인 — 프레임마다 시드가 바뀌는 노이즈
const Grain: React.FC<{opacity: number; freq?: number; blend?: React.CSSProperties['mixBlendMode']}> = ({opacity, freq = 0.95, blend = 'multiply'}) => {
const f = useCurrentFrame();
return (
<svg width={W} height={H} style={{position: 'absolute', inset: 0, opacity, mixBlendMode: blend, pointerEvents: 'none'}}>
<filter id={`grain${f}`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency={freq} numOctaves={2} seed={(f * 7919) % 997} stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter={`url(#grain${f})`} />
</svg>
);
};
// 사선 빛 한 번 지나감(분위기 4초, 위상 0·1 에서 화면 밖)
const Sweep: React.FC<{a?: number}> = ({a = 0.5}) => {
const s = -70 + 240 * INOUT(phase(useCurrentFrame()));
return <div style={{position: 'absolute', inset: '-20%', mixBlendMode: 'soft-light', background: `linear-gradient(105deg, rgba(255,252,240,0) ${s - 18}%, rgba(255,252,240,${a}) ${s}%, rgba(255,252,240,0) ${s + 18}%)`}} />;
};
// 넘침 측정: 글자 상자의 실제 폭(px)
const textWidth = (el: HTMLElement | null) => {
if (!el || !(el.scrollWidth > 0)) throw new Error('글자 폭 측정 실패: ' + (el?.dataset.m ?? '요소 없음'));
return el.scrollWidth;
};
// React 가 상태를 반영하고 그릴 때까지 기다린다(측정·continueRender 전에)
const settle = () => new Promise<void>((r) => setTimeout(() => requestAnimationFrame(() => r()), 30));
const report = (r: Record<string, unknown>) => console.log('DUCHAMP_CHECK ' + JSON.stringify(r));
// ── 캐러셀 공통: 5장 한 컴포지션(props.slide = 1~5). 장마다 같은 서체·그리드·색 ──
const N = 5;
const pageLabel = (n: number) => `${String(n).padStart(2, '0')} / ${String(N).padStart(2, '0')}`;
// 2~4장 브랜드 노출 검사: 띄어쓰기·대소문자 무시하고 브랜드명이 들어 있는 글자를 찾는다
const squash = (s: string) => s.replace(/\s+/g, '').toLowerCase();
const brandLeaks = (brand: string, texts: string[]) => (squash(brand).length < 2 ? [] : texts.filter((t) => squash(t).includes(squash(brand))));
// 판 크롭: 판을 장 수만큼 만들지 않고 같은 판을 장마다 다른 배율·중심으로 잘라 쓴다(같은 톤 보장). s ≥ 1, (fx, fy) = 확대 중심(0~1)
type Crop = {s: number; fx: number; fy: number};
// 판 <Img> 자체에 건다(감싸는 div 에 걸면 그 안에서 mix-blend-mode 가 갇힌다)
const cropStyle = (c: Crop): React.CSSProperties => ({transformOrigin: `${c.fx * 100}% ${c.fy * 100}%`, transform: `scale(${c.s})`});
const rgba = (hex: string, a: number) => { const [r, g, b] = hexRgb(hex); return `rgba(${r},${g},${b},${a})`; };
const checkSlide = (p: {slide: number}) => { if (!(p.slide >= 1 && p.slide <= N)) throw new Error('slide 는 1~5'); };
type Rect = {x: number; y: number; w: number; h: number};
// ── 사진 캐러셀 공통(플래시 스냅·미술관 캡션·매크로 질감, 2026-10-02): 판을 장마다 자르고 보정 → 글자 자리 검사 → 글자 대비 받침 ──
// 판은 글자 없는 사진(flash 3장 a·b·c, museum·macro 4~5장 a~e). 다섯 장은 장별 표(plateUse)대로 판을 고르고(자르기 1.3배 이하) 같은 규칙으로 보정한다 → 장 사이 톤이 같다.
// 글자는 전부 이 코드가 실제 폰트로 얹는다(이미지 모델은 글자를 그리지 않는다 → 받침·자모 오류 0).
const CROP_MAX = 1.3;
type PlateUse = {key: 'a' | 'b' | 'c' | 'd' | 'e'; crop: Crop; flip?: boolean; blur?: number}; // 판 a~e(flash 는 a·b·c, museum·macro 는 a~d + 선택 e)
// 사진 한 장을 장의 크롭대로 W×H 캔버스에 그린다(cover 배치 → 확대 → 좌우 뒤집기 → 흐림)
const drawPlate = async (src: string, u: PlateUse) => {
const i = await loadImage(src);
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d', {willReadFrequently: true})!;
if (u.flip) { g.translate(W, 0); g.scale(-1, 1); }
g.translate(u.crop.fx * W, u.crop.fy * H); g.scale(u.crop.s, u.crop.s); g.translate(-u.crop.fx * W, -u.crop.fy * H);
if (u.blur) g.filter = `blur(${u.blur}px)`;
drawCover(g, i);
g.setTransform(1, 0, 0, 1, 0, 0); g.filter = 'none';
return {c, g, d: g.getImageData(0, 0, W, H), iw: i.width, ih: i.height};
};
const Y = (d: Uint8ClampedArray, k: number) => (0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]) / 255; // 감마 공간 밝기(보정용)
const lin = (v: number) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
const relLum = (r: number, g: number, b: number) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); // WCAG 상대 밝기
const contrastOf = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const quant = (xs: number[] | Float32Array, q: number) => { const s = Float32Array.from(xs).sort(); return s.length ? s[Math.min(s.length - 1, Math.max(0, Math.floor(q * (s.length - 1))))] : 0; };
const mix = (a: number[], b: number[], t: number) => a.map((v, k) => v + (b[k] - v) * t);
const hueOf = (c: number[]) => { const [r, g, b] = c.map((v) => v / 255), mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; if (d < 1e-3) return {h: 0, s: 0}; const h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; return {h: (h * 60 + 360) % 360, s: d / (1 - Math.abs(mx + mn - 1) + 1e-6)}; };
const hexOf = (c: number[]) => '#' + c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('').toUpperCase();
// 밝기 레벨: 밝기 0.5%·99.5% 지점을 black·white 로 펴고 감마로 중앙값을 mid 에 맞춘다. 색은 밝기 비율로 같이 움직여 채도를 지킨다
// maxGain: 대비를 이 배수보다 크게 펴지 않는다(좁은 범위의 판을 억지로 펴면 광택이 하얗게 튀고 탁해진다 — 2회차 매크로 실측). mid 가 함수면 원래 중앙값을 받아 목표를 정한다
const levels = (d: ImageData, black: number, white: number, mid: number | ((med: number) => number), sample: Rect | null = null, maxGain = Infinity) => {
const a = d.data, ys: number[] = [];
const x0 = sample ? Math.max(0, Math.round(sample.x)) : 0, y0 = sample ? Math.max(0, Math.round(sample.y)) : 0;
const x1 = sample ? Math.min(W, Math.round(sample.x + sample.w)) : W, y1 = sample ? Math.min(H, Math.round(sample.y + sample.h)) : H;
for (let y = y0; y < y1; y += 3) for (let x = x0; x < x1; x += 3) ys.push(Y(a, (y * W + x) * 4));
const lo = quant(ys, 0.005), hi = Math.max(lo + 0.05, quant(ys, 0.995)), med = quant(ys, 0.5);
if ((white - black) / (hi - lo) > maxGain) { const r = (hi - lo) * maxGain, c = Math.min(1 - r / 2, Math.max(r / 2, (lo + hi) / 2)); black = c - r / 2; white = c + r / 2; }
const target = typeof mid === 'function' ? mid(med) : mid;
const st = (v: number) => black + (white - black) * Math.min(1, Math.max(0, (v - lo) / (hi - lo)));
const gamma = Math.log(target) / Math.log(Math.max(0.02, Math.min(0.98, st(med))));
const lut = new Float32Array(256); for (let i = 0; i < 256; i++) lut[i] = Math.pow(st(i / 255), gamma);
for (let k = 0; k < a.length; k += 4) {
const y = Y(a, k), t = lut[Math.round(y * 255)], r = y > 0.004 ? t / y : 0;
if (y <= 0.004) { a[k] = a[k + 1] = a[k + 2] = t * 255; continue; }
// 밝은 쪽에서 채널이 넘치면 흰색 쪽으로 섞어 색이 깨지지 않게
const R = a[k] * r, G = a[k + 1] * r, B = a[k + 2] * r, mx = Math.max(R, G, B);
const over = mx > 255 ? (mx - 255) / Math.max(1, mx - t * 255) : 0;
a[k] = R + (t * 255 - R) * over; a[k + 1] = G + (t * 255 - G) * over; a[k + 2] = B + (t * 255 - B) * over;
}
return {lo: +lo.toFixed(3), hi: +hi.toFixed(3), median: +med.toFixed(3), gamma: +gamma.toFixed(2), target: +target.toFixed(3), black: +black.toFixed(3), white: +white.toFixed(3)};
};
const medianY = (d: ImageData, r: Rect | null = null) => {
const a = d.data, ys: number[] = [];
const x0 = r ? Math.max(0, Math.round(r.x)) : 0, y0 = r ? Math.max(0, Math.round(r.y)) : 0, x1 = r ? Math.min(W, Math.round(r.x + r.w)) : W, y1 = r ? Math.min(H, Math.round(r.y + r.h)) : H;
for (let y = y0; y < y1; y += 4) for (let x = x0; x < x1; x += 4) ys.push(Y(a, (y * W + x) * 4));
return +quant(ys, 0.5).toFixed(3);
};
const meanRgb = (d: ImageData, r: Rect) => {
const a = d.data, s = [0, 0, 0]; let n = 0;
for (let y = Math.max(0, Math.round(r.y)); y < Math.min(H, r.y + r.h); y += 3) for (let x = Math.max(0, Math.round(r.x)); x < Math.min(W, r.x + r.w); x += 3) { const k = (y * W + x) * 4; s[0] += a[k]; s[1] += a[k + 1]; s[2] += a[k + 2]; n++; }
return s.map((v) => v / Math.max(1, n));
};
const toUrl = (c: HTMLCanvasElement, g: CanvasRenderingContext2D, d: ImageData) => { g.putImageData(d, 0, 0); return c.toDataURL('image/jpeg', 0.93); };
// 글자 자리의 바탕 복잡도(피드 크기 540 기준): |판 − 1.5px 흐린 판| 평균(hf)과, 크게 튀는 덩어리 비율(busy, 27px 칸 평균 > 0.05)
const busyOf = (c: HTMLCanvasElement, rects: Rect[]) => {
const w = 540, h = 675, s = document.createElement('canvas'); s.width = w; s.height = h;
const g = s.getContext('2d', {willReadFrequently: true})!; g.drawImage(c, 0, 0, w, h);
const a = g.getImageData(0, 0, w, h).data;
const b2 = document.createElement('canvas'); b2.width = w; b2.height = h;
const gb = b2.getContext('2d', {willReadFrequently: true})!; gb.filter = 'blur(1.5px)'; gb.drawImage(s, 0, 0);
const b = gb.getImageData(0, 0, w, h).data;
return rects.map((r) => {
const x0 = Math.max(0, Math.floor(r.x / 2)), y0 = Math.max(0, Math.floor(r.y / 2)), x1 = Math.min(w, Math.ceil((r.x + r.w) / 2)), y1 = Math.min(h, Math.ceil((r.y + r.h) / 2));
let sum = 0, n = 0; const T = 27, tiles = new Map<number, [number, number]>();
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const k = (y * w + x) * 4, v = Math.abs(Y(a, k) - Y(b, k)); sum += v; n++;
const key = Math.floor((y - y0) / T) * 1000 + Math.floor((x - x0) / T); const t = tiles.get(key) ?? [0, 0]; t[0] += v; t[1]++; tiles.set(key, t);
}
const tv = [...tiles.values()].filter((t) => t[1] > T * T * 0.5).map((t) => t[0] / t[1]);
return {hf: +(sum / Math.max(1, n)).toFixed(4), busy: +(tv.filter((v) => v > 0.05).length / Math.max(1, tv.length)).toFixed(2)};
});
};
// 글자 대비: 글자 상자 아래 바탕 픽셀(받침 scrim 알파 a 를 섞은 뒤)의 상대 밝기 중 글자에 가장 불리한 쪽(밝은 글자면 상위 4%, 어두운 글자면 하위 4%)과 글자색의 대비.
// 기준: 큰 글자(60px 이상) 3.0, 본문 4.5. 모자라면 받침 알파를 올린다(최대 aMax). 받침은 상자·판이 아니라 위아래로 길게 번지는 색 그늘이다
type TextBox = Rect & {size: number; id: string};
const contrastPlan = (d: ImageData, boxes: TextBox[], ink: string, scrim: string, aMax: number) => {
const a = d.data, inkC = hexRgb(ink), sc = hexRgb(scrim), inkL = relLum(inkC[0], inkC[1], inkC[2]), light = inkL > 0.4;
const samples = boxes.map((r) => {
const px: number[] = [];
const step = Math.max(2, Math.round(Math.sqrt((r.w * r.h) / 6000)));
for (let y = Math.max(0, Math.round(r.y)); y < Math.min(H, r.y + r.h); y += step) for (let x = Math.max(0, Math.round(r.x)); x < Math.min(W, r.x + r.w); x += step) { const k = (y * W + x) * 4; px.push(a[k], a[k + 1], a[k + 2]); }
return px;
});
const ratioAt = (al: number) => boxes.map((r, i) => {
const px = samples[i], ls: number[] = [];
for (let k = 0; k < px.length; k += 3) ls.push(relLum(px[k] + (sc[0] - px[k]) * al, px[k + 1] + (sc[1] - px[k + 1]) * al, px[k + 2] + (sc[2] - px[k + 2]) * al));
const worst = quant(ls, light ? 0.96 : 0.04);
return {id: r.id, need: r.size >= 60 ? 3 : 4.5, ratio: +contrastOf(inkL, worst).toFixed(2)};
});
const okAt = (al: number) => ratioAt(al).every((x) => x.ratio >= x.need);
let lo = 0, hi = aMax, al = 0;
if (!okAt(0)) { if (!okAt(aMax)) al = aMax; else { for (let t = 0; t < 12; t++) { const m = (lo + hi) / 2; if (okAt(m)) hi = m; else lo = m; } al = hi; } }
const per = ratioAt(al);
return {alpha: +al.toFixed(3), ok: per.every((x) => x.ratio >= x.need), light, boxes: per};
};
// 받침 그늘: [y0, y1] 은 알파 a 그대로, 바깥으로 feather px 동안 0 까지(위아래로만 번진다)
const Scrim: React.FC<{y0: number; y1: number; a: number; color: string; feather?: number}> = ({y0, y1, a, color, feather = 180}) => {
if (a <= 0.001) return null;
const top = y0 - feather, h = y1 - y0 + 2 * feather, p0 = (feather / h) * 100, p1 = ((feather + y1 - y0) / h) * 100;
return <div style={{position: 'absolute', left: 0, right: 0, top, height: h, pointerEvents: 'none',
background: `linear-gradient(180deg, ${rgba(color, 0)} 0%, ${rgba(color, a * 0.55)} ${p0 * 0.55}%, ${rgba(color, a)} ${p0}%, ${rgba(color, a)} ${p1}%, ${rgba(color, a * 0.55)} ${p1 + (100 - p1) * 0.45}%, ${rgba(color, 0)} 100%)`}} />;
};
// 줄 나누기: 사용자가 ' | ' 로 줄을 정했으면 그대로. 아니면(wordSp = 낱말 사이 간격 보정, em): 한 줄과 빈칸에서 두 줄로 나눈 안 중 글자가 가장 크게 들어가는 것(두 줄은 한 글자 줄·너무 기운 줄에 벌점)
const fitTitle = (text: string, font: (px: number) => string, maxW: number, caps: [number, number], maxH: number, lh: number, wordSp = 0) => {
const x = document.createElement('canvas').getContext('2d')!;
const wOf = (t: string) => { x.font = font(100); x.wordSpacing = `${wordSp * 100}px`; return x.measureText(t).width; };
const forced = text.replace(/\s*\|\s*/g, '\n').split('\n').map((t) => t.trim()).filter(Boolean);
const cands: string[][] = forced.length > 1 ? [forced] : [[forced[0] ?? '']];
if (forced.length <= 1) { const t = forced[0] ?? ''; [...t].forEach((c, i) => { if (c === ' ') cands.push([t.slice(0, i).trim(), t.slice(i + 1).trim()]); }); }
let best = {lines: cands[0], size: 0, score: -1};
for (const ls of cands) {
if (ls.length > 2 || ls.some((l) => !l)) continue;
const ws = ls.map(wOf), cap = caps[ls.length - 1];
const size = Math.min(cap, (maxW / Math.max(...ws)) * 100, maxH / (ls.length * lh));
const bal = ls.length === 2 ? Math.min(...ws) / Math.max(...ws) : 1;
const short = ls.length === 2 && ls.some((l) => [...l.replace(/\s/g, '')].length < 2);
const score = size * (ls.length === 2 ? 0.94 + 0.06 * bal : 1) * (short ? 0.6 : 1);
if (score > best.score) best = {lines: ls, size, score};
}
x.font = font(best.size); x.wordSpacing = `${wordSp * best.size}px`;
return {lines: best.lines, size: Math.floor(best.size), widths: best.lines.map((l) => x.measureText(l).width)};
};
const widthAt = (t: string, font: string, wordSpPx = 0) => { const x = document.createElement('canvas').getContext('2d')!; x.font = font; x.wordSpacing = `${wordSpPx}px`; return x.measureText(t).width; };
// 글자 잉크 상자(캔버스 실측): 한 줄 글자의 기준선 위·아래 잉크 높이
const inkOf = (t: string, font: string) => { const x = document.createElement('canvas').getContext('2d')!; x.font = font; const m = x.measureText(t || '가'); return {asc: m.actualBoundingBoxAscent, desc: m.actualBoundingBoxDescent, fa: m.fontBoundingBoxAscent, fd: m.fontBoundingBoxDescent, w: m.width}; };
// 화면 글자 상자([data-t]) → 대비·자리 검사용 상자(id = data-t 값, size = data-s)
const boxesOf = (root: HTMLElement | null): TextBox[] => {
if (!root) return [];
const rr = root.getBoundingClientRect(), k = W / Math.max(1, rr.width);
return [...root.querySelectorAll<HTMLElement>('[data-t]')].map((el) => { const b = el.getBoundingClientRect(); return {x: (b.left - rr.left) * k, y: (b.top - rr.top) * k, w: b.width * k, h: b.height * k, size: Number(el.dataset.s ?? 40), id: el.dataset.t ?? ''}; }).filter((r) => r.w > 1 && r.h > 1);
};
const over1 = (r: Rect) => r.x < -1 || r.y < -1 || r.x + r.w > W + 1 || r.y + r.h > H + 1;
// ── Duchamp 코드 레시피 · macro-texture (5장 · 장마다 C 짧게: 등장 1.4s + 분위기 4s) ──
// 화면을 꽉 채운 재료 매크로 사진(글자 없는 판 4~5장 — 장마다 다른 장면) + 아래 45% 의 아주 굵은 글자. 1장 = 제목 한 덩어리 · 2~4장 = 소제목 + 본문 · 5장 = 한 줄 + 브랜드(흐린 질감)
type Body = {head: string; lines: string[]};
type Props = {
slide: number; brand: string; title: string; bodies: Body[]; last: string;
colors: {tone: string; ink: string; accent: string};
plates: {a: string; b: string | null; c: string | null; d: string | null; e?: string | null};
};
const defaults: Props = {
slide: 1, brand: '들꽃 비누공방', title: '비누 한 장의 시간',
bodies: [
{head: '기름을 녹여요', lines: ['올리브유와 코코넛 오일을', '낮은 온도에서 섞어요']},
{head: '천천히 저어요', lines: ['크림처럼 걸쭉해질 때까지', '한 시간 넘게 저어요']},
{head: '그늘에서 말려요', lines: ['사 주 동안 수분이 빠지면', '단단하고 순해져요']},
],
last: '천천히 만든 한 장',
colors: {tone: '#E9E2D0', ink: '#1C1A17', accent: '#7A9E7E'},
plates: {a: 'plates/a.jpg', b: 'plates/b.jpg', c: 'plates/c.jpg', d: 'plates/d.jpg', e: 'plates/e.jpg'},
};
const M = 64, SANS = 'Noto Sans KR', BOTTOM = 96;
const BODY = 44, BODY_LH = 64, PAGE = 30, BAR_W = Math.round(W * 0.12), BAR_H = 16, TRACK = -0.03, LH = 1.08;
const ZONE_Y = Math.round(H * 0.55); // 글자는 이 아래(아래 45%)에만
const ZONE_HF = 0.03; // 글자 자리 바탕 복잡도 상한(540 기준). 넘으면 질감이 글자 자리까지 거칠다 → 판 다시
const SCRIM_MAX = 0.55;
const FONT_CSS = ['https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@500;700;900&display=block'];
// 장별 판(2회차): 장마다 다른 장면 — 1 = a(대표 재료) · 2 = b · 3 = c · 4 = d(2~4장 글자의 재료·상태·도구·단면) · 5 = e(마무리 장면, 1.1배 + 흐림 4px).
// 뒤집기·같은 판 재사용 금지(1회차 sheet: 4장이 1장 판을 뒤집은 것이 꽃잎 배치로 바로 보였다). 재사용은 1·5장만: e 가 없으면 5장 = a 를 1.15배 + 흐림 6px(경고 없음).
// b·c·d 가 없을 때만 a 를 다른 자리로 잘라 채우고 warn: plate-reuse
const plateUse = (p: Props, n: number): {src: string; use: PlateUse; reuse: boolean} => {
const T: PlateUse[] = [
{key: 'a', crop: {s: 1, fx: 0.5, fy: 0.5}},
{key: 'b', crop: {s: 1, fx: 0.5, fy: 0.5}},
{key: 'c', crop: {s: 1, fx: 0.5, fy: 0.5}},
{key: 'd', crop: {s: 1, fx: 0.5, fy: 0.5}},
{key: 'e', crop: {s: 1.1, fx: 0.5, fy: 0.3}, blur: 4},
];
const F: PlateUse[] = [T[0], {key: 'a', crop: {s: 1.3, fx: 0.15, fy: 0.2}}, {key: 'a', crop: {s: 1.3, fx: 0.85, fy: 0.1}}, {key: 'a', crop: {s: 1.3, fx: 0.5, fy: 0}}, {key: 'a', crop: {s: 1.15, fx: 0.5, fy: 0.3}, blur: 6}];
const has = (k: PlateUse['key']) => !!(p.plates as Record<string, string | null | undefined>)[k];
const u = has(T[n - 1].key) ? T[n - 1] : F[n - 1];
return {src: (p.plates as Record<string, string>)[u.key], use: u, reuse: u !== T[n - 1] && n !== 5};
};
// 색조 맞춤(2회차): 판의 원래 색·밝기를 살리면서 브랜드 색조 쪽으로 당긴다.
// ① 밝기: 대비는 1.5배까지만 펴고, 중앙값 목표 = 판 중앙값과 색조 밝기의 중간 ② 색: 밝기 → [짙은 색조 · 색조 · 밝은 색조] 맵을 섞는데,
// 판과 색조의 색상(hue)이 35° 안이면 0.15(이미 같은 계열 — 1회차에 0.6 으로 섞었더니 주황 귤잼이 갈색 진흙이 됐다), 아니면 0.4
// ③ 보정 강도 규칙(밝기 차가 큰 판, 13차): 판 중앙값과 색조 밝기 차가 0.3 을 넘으면(밝은 판 + 어두운 색조, 또는 그 반대) 밝기는 0.15 까지만 옮기고
// 곱하기로만(대비 펴기·감마 없음 — 밝은 미색 비누 판을 색조 밝기 쪽으로 0.3 끌어내렸더니 채도가 빠진 회갈색 #685A45 로 물들었다), 색은 짙은·밝은 맵 대신
// "그 픽셀 밝기의 색조"(색조 RGB 를 픽셀 밝기에 맞춘 색)를 0.3 섞는다 → 밝기는 판대로, 색만 색조 쪽으로. 모자란 글자 대비는 그늘(최대 0.7)이 맡는다
const FAR = 0.3, FAR_SHIFT = 0.15, FAR_TINT = 0.3, SCRIM_FAR = 0.7;
const prepareTexture = async (p: Props, n: number) => {
const {src, use} = plateUse(p, n);
const {c, g, d} = await drawPlate(staticFile(src), use);
const tone = hexRgb(p.colors.tone), tl = (0.2126 * tone[0] + 0.7152 * tone[1] + 0.0722 * tone[2]) / 255;
const before = meanRgb(d, {x: 0, y: 0, w: W, h: H});
const med0 = medianY(d), far = Math.abs(med0 - tl) > FAR;
let lv: Record<string, number>;
if (far) { // 밝기는 곱하기로만 옮긴다(감마로 옮기면 밝은 쪽 결의 대비가 1.5배 커져 글자 자리가 거칠어졌다 — zone-busy 0.036·0.040)
const target = Math.min(0.85, Math.max(0.15, med0 + Math.sign(tl - med0) * FAR_SHIFT)), r = target / Math.max(0.02, med0), a0 = d.data;
for (let q = 0; q < a0.length; q += 4) { a0[q] = Math.min(255, a0[q] * r); a0[q + 1] = Math.min(255, a0[q + 1] * r); a0[q + 2] = Math.min(255, a0[q + 2] * r); }
lv = {median: med0, target: +target.toFixed(3), scale: +r.toFixed(3)};
} else lv = levels(d, 0.04, 0.97, (med) => Math.min(0.8, Math.max(0.25, (med + tl) / 2)), null, 1.5);
const mid = lv.target;
const hp = hueOf(before), ht = hueOf(tone), dh = Math.min(Math.abs(hp.h - ht.h), 360 - Math.abs(hp.h - ht.h));
const k = far ? FAR_TINT : hp.s < 0.12 || ht.s < 0.12 ? 0.3 : dh < 35 ? 0.15 : 0.4;
const sh = mix(tone, [0, 0, 0], 0.72), hi = mix(tone, [255, 255, 255], 0.78), a = d.data;
for (let q = 0; q < a.length; q += 4) {
const y = Y(a, q);
let m: number[];
if (far) { // 픽셀 밝기의 색조: 색조 RGB × (y / 색조 밝기), 넘치는 채널은 흰색 쪽으로
const r = y / Math.max(0.02, tl), t = tone.map((v) => v * r), mx = Math.max(...t), o = mx > 255 ? (mx - 255) / Math.max(1, mx - y * 255) : 0;
m = t.map((v) => v + (y * 255 - v) * o);
} else m = y < mid ? mix(sh, tone, y / mid) : mix(tone, hi, (y - mid) / Math.max(0.01, 1 - mid));
a[q] += (m[0] - a[q]) * k; a[q + 1] += (m[1] - a[q + 1]) * k; a[q + 2] += (m[2] - a[q + 2]) * k;
}
g.putImageData(d, 0, 0);
return {c, g, d, far, grade: {...lv, far, mapMix: k, hueDiff: Math.round(dh), before: hexOf(before), after: hexOf(meanRgb(d, {x: 0, y: 0, w: W, h: H}))}};
};
const T42x = (x: number) => (x * INTRO) / 42;
const Macro: React.FC<Props> = (p) => {
const n = p.slide;
const [handle] = useState(() => delayRender('fonts+layout'));
const rootRef = React.useRef<HTMLDivElement>(null);
const [lay, setLay] = useState({ready: false, url: '', title: {lines: [] as string[], size: 100}, body: BODY, brandSize: 60, scrim: {y0: 0, y1: 0, a: 0}, hdrA: 0, pageColor: p.colors.accent});
const light = relLum(...(hexRgb(p.colors.ink) as [number, number, number])) > 0.4;
const scrimColor = hexOf(light ? mix(hexRgb(p.colors.tone), [0, 0, 0], 0.68) : mix(hexRgb(p.colors.tone), [255, 255, 255], 0.62));
useEffect(() => {
(async () => {
checkSlide(p);
if (p.bodies.length !== 3) throw new Error('bodies 는 2~4장 세 개');
await loadFonts(FONT_CSS, [
[`900 100px "${SANS}"`, [p.title, ...p.bodies.map((b) => b.head), p.last, p.brand].join('')],
[`500 ${BODY}px "${SANS}"`, p.bodies.flatMap((b) => b.lines).join('') || '가'],
[`700 ${PAGE}px "${SANS}"`, p.brand + '0123456789'],
]);
const plate = await prepareTexture(p, n);
// 장 번호 색: 포인트색이 판 위쪽에서 대비 3 미만이면 글자색(1회차 Claude 실행: 밝은 질감 위 연두색 01 이 옅다)
const pageBox = [{x: M, y: 56, w: 60, h: 40, size: 60, id: 'page'}];
const pageColor = contrastPlan(plate.d, pageBox, p.colors.accent, scrimColor, 0).boxes[0].ratio >= 3 ? p.colors.accent : p.colors.ink;
const hf = (px: number) => `900 ${px}px "${SANS}"`;
const x = document.createElement('canvas').getContext('2d')!;
// 자간 −0.03em 을 잰 폭에 넣는다(캔버스 letterSpacing = 화면과 같은 값)
const fit = (t: string, caps: [number, number], maxH: number) => {
const r = fitTitle(t, hf, (W - 2 * M) / (1 + TRACK * 0.9), caps, maxH, LH);
x.font = hf(r.size); x.letterSpacing = `${TRACK * r.size}px`;
const w = Math.max(...r.lines.map((l) => x.measureText(l).width));
return {...r, size: w > W - 2 * M ? Math.floor((r.size * (W - 2 * M)) / w) : r.size};
};
let title = n === 1 ? fit(p.title, [240, 200], H - BOTTOM - ZONE_Y - BAR_H - 40) : n === 5 ? fit(p.last, [176, 150], H * 0.3) : fit(p.bodies[n - 2].head, [132, 116], H * 0.2);
const headAll = Math.min(...p.bodies.map((b) => fit(b.head, [132, 116], H * 0.2).size));
if (n >= 2 && n <= 4) title = {...title, size: headAll};
const bw = Math.max(1, ...p.bodies.flatMap((b) => b.lines).map((t) => widthAt(t, `500 ${BODY}px "${SANS}"`)));
const body = BODY * Math.min(1, (W - 2 * M) / bw);
const brandSize = Math.min(Math.round(title.size * 0.42), 72);
setLay((l) => ({...l, ready: true, url: plate.c.toDataURL('image/jpeg', 0.94), title: {lines: title.lines, size: title.size}, body, brandSize, pageColor}));
await settle();
const boxes = boxesOf(rootRef.current), tb = boxes.filter((b) => b.id.startsWith('txt:'));
const scrimMax = plate.far ? SCRIM_FAR : SCRIM_MAX;
const pl = contrastPlan(plate.d, tb, p.colors.ink, scrimColor, scrimMax);
const y0 = Math.min(...tb.map((b) => b.y)) - 30, y1 = Math.max(...tb.map((b) => b.y + b.h)) + 30;
// 1장 위 브랜드: 위쪽 결이 진하면 위 띠에만 같은 그늘(최대 0.5)
const hdr = boxes.filter((b) => b.id.startsWith('hdr:'));
const hdrPlan = hdr.length ? contrastPlan(plate.d, hdr, p.colors.ink, scrimColor, plate.far ? SCRIM_FAR : 0.5) : null;
setLay((l) => ({...l, scrim: {y0, y1, a: pl.alpha}, hdrA: hdrPlan?.alpha ?? 0}));
await settle();
const over: string[] = [], warn: string[] = [];
if (hdrPlan && !hdrPlan.ok) over.push('contrast-brand');
const [zone] = busyOf(plate.c, [{x: 0, y: y0, w: W, h: y1 - y0}]);
const after = boxesOf(rootRef.current);
if (after.some(over1) || after.filter((b) => b.id.startsWith('txt:')).some((b) => b.x + b.w > W - M + 2)) over.push('frame');
if (n !== 5 && Math.min(...tb.map((b) => b.y)) < ZONE_Y - 2) over.push('zone-height');
if (title.lines.length > 2) over.push('title-lines');
if (n >= 2 && n <= 4) { const ls = p.bodies[n - 2].lines; if (ls.length < 1 || ls.length > 3) over.push('lines-count'); }
if (body < BODY * 0.8) over.push('body');
if (zone.hf > ZONE_HF) over.push('zone-busy');
if (!pl.ok) over.push('contrast');
const inkW = n === 1 ? Math.max(...after.filter((b) => b.id.startsWith('txt:title')).map((b) => b.w)) : 0;
if (n === 1 && inkW < W * 0.7) warn.push(`title-narrow: 제목 폭 ${Math.round(inkW)}px < 화면 70% — 제목이 짧아 상한 크기에 걸림`);
const u = plateUse(p, n);
if (u.use.crop.s > CROP_MAX) over.push('crop-scale');
if (u.reuse) warn.push('plate-reuse: 판이 모자라 a 를 다른 자리로 잘라 썼다 → b·c·d(2~4장 글자의 장면)를 넣으면 장마다 다른 장면');
const leaks = n >= 2 && n <= 4 ? brandLeaks(p.brand, [p.bodies[n - 2].head, ...p.bodies[n - 2].lines]) : [];
if (leaks.length) over.push('brand-2to4');
const hint = over.length ? '넘침·규칙 위반 = ' + [...new Set(over)].join(', ') + (leaks.length ? ' (2~4장에는 브랜드명을 쓰지 않는다: ' + leaks.join(' | ') + ')' : '')
+ (over.includes('zone-busy') || over.includes('contrast') || over.includes('contrast-brand') ? ' / 글자 자리(아래 45%)의 질감이 너무 거칠거나 밝기가 들쭉날쭉해 글자가 묻힌다 → 그 판을 판 조건(아래 45% 는 가장 고르고 매끈한 결)대로 다시 생성' : '')
+ (over.some((o) => ['lines-count', 'body', 'title-lines'].includes(o)) ? ' → 글자를 줄인 안을 사용자에게 물어본 뒤 props.json 을 고쳐 다시' : '') : null;
report({slide: n, ok: over.length === 0, overflow: [...new Set(over)], warn, plate: {src: u.src, ...u.use}, grade: plate.grade, title: {lines: title.lines, size: title.size, inkWidth: Math.round(inkW) || undefined, headSizeAll: n >= 2 && n <= 4 ? headAll : undefined},
body: {size: +body.toFixed(1)}, zone: {...zone, limit: ZONE_HF}, contrast: pl, brandContrast: hdrPlan ? {ratio: hdrPlan.boxes[0]?.ratio, alpha: hdrPlan.alpha} : undefined, pageColor, scrim: {alpha: pl.alpha, color: scrimColor, max: scrimMax}, hint});
await settle();
continueRender(handle);
})().catch((e) => cancelRender(e));
}, []);
const frame = useCurrentFrame();
const f = frame - INTRO;
const focus = 1 - ramp(frame, 0, T42x(26));
const drift = frame >= INTRO ? Math.sin((2 * Math.PI * f) / LOOP) * 8 : 0;
const breath = frame >= INTRO ? 1 + 0.012 * (1 - Math.cos((2 * Math.PI * f) / LOOP)) / 2 : 1;
const t = lay.title, sz = t.size, lineH = Math.round(sz * LH);
const big: React.CSSProperties = {fontFamily: SANS, fontWeight: 900, color: p.colors.ink, whiteSpace: 'pre', letterSpacing: `${TRACK}em`};
const rise = (k: number, s: number, h: number): React.CSSProperties => ({transform: `translateY(${enter(frame, T42x(s + k * 4), T42x(20)) * h}px)`});
const page = <div style={{position: 'absolute', left: M, top: 56, fontFamily: SANS, fontWeight: 700, fontSize: PAGE, lineHeight: '40px', color: lay.pageColor, opacity: ramp(frame, T42x(20), T42x(32))}}>{String(n).padStart(2, '0')}</div>;
const titleLines = (top: number) => t.lines.map((l, i) => (
<div key={i} style={{position: 'absolute', left: M, top: top + i * lineH, height: lineH, overflow: 'hidden'}}>
<span data-t={`txt:title${i + 1}`} data-s={sz} style={{...big, fontSize: sz, lineHeight: `${lineH}px`, display: 'inline-block', ...rise(i, 10, lineH)}}>{l}</span>
</div>
));
const bar = (top: number) => <div style={{position: 'absolute', left: M, top, width: BAR_W, height: BAR_H, background: p.colors.accent, transformOrigin: '0 50%', transform: `scaleX(${ramp(frame, T42x(6), T42x(22))})`}} />;
let content: React.ReactNode;
if (n === 1) {
const top = H - BOTTOM - t.lines.length * lineH;
content = <>{bar(top - BAR_H - 34)}{titleLines(top)}
<div style={{position: 'absolute', right: M, top: 56, opacity: ramp(frame, T42x(20), T42x(32))}}><span data-t="hdr:brand" data-s={PAGE} style={{fontFamily: SANS, fontWeight: 700, fontSize: PAGE, lineHeight: '40px', color: p.colors.ink, whiteSpace: 'pre'}}>{p.brand}</span></div>
</>;
} else if (n <= 4) {
const b = p.bodies[n - 2];
const bodyTop = H - BOTTOM - b.lines.length * BODY_LH, top = bodyTop - 26 - t.lines.length * lineH;
content = <>{bar(top - BAR_H - 30)}{titleLines(top)}
{b.lines.map((l, i) => (
<div key={i} style={{position: 'absolute', left: M, top: bodyTop + i * BODY_LH, height: BODY_LH, overflow: 'hidden'}}>
<span data-t={`txt:line${i + 1}`} data-s={Math.round(lay.body)} style={{fontFamily: SANS, fontWeight: 500, fontSize: lay.body, lineHeight: `${BODY_LH}px`, color: p.colors.ink, whiteSpace: 'pre', letterSpacing: '-0.01em', display: 'inline-block', ...rise(i, 22, BODY_LH)}}>{l}</span>
</div>
))}</>;
} else {
const top = Math.round(H * 0.5);
content = <>{bar(top - BAR_H - 34)}{titleLines(top)}
<div style={{position: 'absolute', left: M, top: top + t.lines.length * lineH + 18, height: Math.round(lay.brandSize * 1.3), overflow: 'hidden'}}>
<span data-t="txt:brand" data-s={lay.brandSize} style={{...big, fontSize: lay.brandSize, lineHeight: `${Math.round(lay.brandSize * 1.3)}px`, display: 'inline-block', ...rise(3, 18, lay.brandSize * 1.3)}}>{p.brand}</span>
</div></>;
}
return (
<AbsoluteFill style={{backgroundColor: p.colors.tone, overflow: 'hidden', opacity: lay.ready ? 1 : 0}}>
<div ref={rootRef} style={{position: 'absolute', inset: 0}}>
{lay.url && <Img src={lay.url} style={{position: 'absolute', width: W, height: H, filter: focus > 0.001 ? `blur(${(10 * focus).toFixed(2)}px)` : undefined, transform: `translateX(${drift}px) scale(${(breath + 0.05 * focus).toFixed(4)})`}} />}
<Scrim y0={0} y1={110} a={lay.hdrA} color={scrimColor} feather={120} />
<Scrim y0={lay.scrim.y0} y1={Math.min(H, lay.scrim.y1)} a={lay.scrim.a} color={scrimColor} feather={200} />
{content}
{page}
</div>
<Grain opacity={0.06} blend="soft-light" freq={0.9} />
</AbsoluteFill>
);
};
registerRoot(() => <Composition id="Main" component={Macro} width={W} height={H} fps={FPS} durationInFrames={INTRO + LOOP} defaultProps={defaults} />);
```
`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 png # 출력: PNG만 → out/s1.png … out/s5.png
node render.mjs mp4 # 출력: 장별 MP4까지 → out/s1.mp4 … out/s5.mp4 (PNG 도 함께)
```
- 한 번 실행에 다섯 장을 같은 코드·같은 폰트로 차례로 렌더한다(장마다 `props.json` + `slide: 1~5`). 장 하나만 다시 볼 때는 `node render.mjs png 3` 처럼 번호를 붙인다(그때 `check.json` 에는 그 장만 남으니, 끝내기 전에는 번호 없이 다섯 장을 다시 렌더한다).
- 렌더 중 브라우저가 폰트·판 이미지를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
- **종료 코드 2 = 검사 실패 → 거기서 멈춘다**(MP4 를 만들지 않는다). `out/check.json` 의 장별 `overflow`·`hint` 를 읽고 원인을 고친 뒤 다시 실행한다. 글자를 줄이거나 바꿔야 하면 마음대로 고치지 말고 사용자에게 고친 안을 물어본다.
## 7. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: 맨 위 `ok: true`, 다섯 장 모두 `ok: true`·`overflow: []`. `overflow` 뜻: `title-lines`(세 줄 이상 필요 — 줄이기), `zone-height`(글자 덩어리가 아래 45% 위로 올라감 — 줄이기), `lines-count`(본문 줄 수 1~3 밖), `body`(본문 줄이 길어 0.8배보다 더 줄여야 함), `zone-busy`(글자 밑 질감이 거칢 = 판 문제 → 그 판 다시), `contrast`·`contrast-brand`(그늘을 최대로 깔아도 글자·1장 위 브랜드 대비 부족 → 그 판 다시 또는 글자색 확인), `frame`(화면 밖 — 코드 문제), `crop-scale`(코드 문제), `brand-2to4`(2~4장에 브랜드명). 장마다 `grade`(밝기 레벨 — 대비는 1.5배까지만 펴고 중앙값은 판과 색조 밝기의 중간, 색조 맵 섞음 비율 mapMix — 판과 색조가 같은 색 계열(hueDiff < 35°)이면 0.15 로 판 색을 살리고 아니면 0.4, before → after 평균색. `far: true` 면 보정 강도 규칙 — 밝기 곱하기 scale 로 0.15 만 옮기고 mapMix 0.3, 그늘 최대 0.7), `zone.hf`(0.03 이하), `contrast.boxes[].ratio`(큰 글자 3.0·본문 4.5 이상), `scrim.alpha`, `title.headSizeAll`(2~4장 공통)이 남는다. `brandContrast`(1장 위 브랜드 대비·위 띠 그늘), `pageColor`(장 번호 색 — 포인트색이 판 위에서 대비 3 미만이면 글자색), `warn`(`title-narrow`·`plate-reuse`)은 보고에 쓴다.
2. `out/s1.png` ~ `out/s5.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구). 확인: 제목·소제목·본문·마무리 문장·브랜드가 입력과 글자 하나까지 같은지(순서·띄어쓰기·받침 포함), 2~4장에 브랜드명이 없는지, 2~4장 소제목 크기·위치가 같은지, 글자가 질감에 묻히지 않는지(가로 540px 로 줄여서도), 판에 물방울·거품·글자·지저분한 얼룩이 없는지, **다섯 장이 서로 다른 장면인지(뒤집거나 다시 쓴 판이 1·5장 밖에 없는지)**, 다섯 장 색조가 한 벌로 보이는지, 밝은 판이 회갈색·진흙색으로 물들지 않았는지, 1장 제목 줄바꿈이 뜻을 끊지 않는지(끊으면 `|` 로 줄을 정해 다시).
3. MP4 이면 `out/frames/` 의 장별 4장(0·0.7·1.4·5.36초)을 본다: 0초는 흐린 질감, 0.7초는 초점이 맞아 가며 글자가 올라오는 중, 1.4초부터 완성, 끝 프레임도 완성. 영상마다 5.4초, 1080×1350, 30fps.
4. 문제가 있으면 원인(판·글자 수·props)을 고쳐 다시 렌더한다.
## 8. 보고
결과 파일 경로(`out/s1.png`~`out/s5.png`, MP4 면 `out/s1.mp4`~`out/s5.mp4`), 배경판을 어떻게 얻었는지(생성 몇 번·내 사진·예시 판·ChatGPT)와 check.json 의 장별 `title.size`·`zone.hf`·`contrast`(최소 대비)·`scrim.alpha`·`grade.mapMix`·`warn`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 인스타그램 캐러셀에는 PNG 5장을 순서대로 올린다. MP4 는 장마다 등장 1.4초(초점이 맞고 글자가 올라옴) + 분위기 4초(아주 느린 흐름)다.
폰트(전부 SIL OFL 1.1, 상업 사용 가능): 제목·소제목·5장 마무리 문장·브랜드 Noto Sans KR 900, 본문 Noto Sans KR 500, 장 번호·1장 브랜드 Noto Sans KR 700.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요. 영상·ChatGPT 이미지 프롬프트는 복사 옆 ▾ 에서 고르세요.
- 붙여 넣기
Claude Code·Codex 같은 코딩 에이전트에 붙여 넣고 브랜드·색·주제·1장~5장 글자를 알려 주세요. 실제 폰트로 5장을 렌더해 글자가 깨지지 않아요.
- 올리기
PNG 5장(원하면 장마다 MP4)을 받아 인스타그램에서 여러 장 선택으로 순서대로 올리세요.
글자 분량
- 1장 · 표지
- 제목 16자 · 보조 24자
- 2~4장 · 본문
- 소제목 16자 · 본문 60자
- 5장 · 마지막 장
- 한 줄 16자
한도를 넘기면 ChatGPT가 그리지 않고 줄여 달라고 되묻습니다.
지킬 것
- 잘 맞는 글: 재료·소재 소개, 맛과 향 설명, 만드는 과정
- 질감 사진은 분위기를 위한 연출입니다. 실제 제품·성분 사진 대신 쓰지 마세요.
- 글자가 질감에 묻히면 다시 그려 달라고 하세요.
- 모든 글자의 받침·모음을 한 번 확인하세요(예: 밤/밥, 붙/불, 꽃/꽂). 틀리면 그 장만 다시 생성하세요.