← 캐러셀
캐러셀 · 타이포
업데이트 안내
코드 렌더 · 같은 결과
바뀐 것 세 가지를 활판으로 찍은 전과 후로 한 장씩 알려 주세요.
계기업데이트
1장1 / 5
가상 브랜드 솔밭 캠핑장로 레시피를 실제 실행해 렌더한 5장입니다. 번호나 화살표로 넘겨 보세요.
레시피 보기120,304자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 업데이트 캐러셀 5장 (활판으로 찍은 "달라진 것" + 전/후)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델에게 글자를 그리게 하지 않는다. 글자 없는 면 종이 판 한 장 위에 실제 폰트로 날짜·제목·항목을 코드로 얹고(활판 잉크·눌린 자국 효과 포함), 헤드리스 브라우저로 5장(PNG, 필요하면 장별 MP4)을 렌더한다. 다섯 장은 한 컴포지션이라 종이·서체·괘선 격자·색이 장마다 같다.
- 1장(표지): 브랜드 + 폭에 꽉 찬 큰 날짜 + 바로 아래 큰 제목 **만**(날짜–제목 간격 72~112px 고정, 덩어리를 괘선 사이 가운데에)
- 2~4장: 바뀐 항목 하나씩 — 큰 번호(01·02·03) + 그 오른쪽에 항목 이름, 전(줄이 그어짐) → 후 + 메모(선택). **항목 이름은 언제나 한 줄**(세 장 모두 한 줄로 들어가는 공통 크기, 64px 아래면 `name` 실패). **후 값 글자 크기는 세 장이 같은 규칙**: 보통 값은 공통 크기 S(세 장 중 가장 빠듯한 값에 맞춘 크기), 짧은 값(S 에서 한 줄 폭이 칸의 절반 이하, 예: "없음"·"8대")은 짧은 값끼리 공통 크기(150px 이상, S×1.3 이상일 때만)로 키운다 — 빈 서식처럼 보이지 않게. 전·후 줄 높이는 남는 높이를 같은 비율(38:62)로 나눠, 메모 없는 장의 빈 곳도 같은 규칙으로 흡수한다
- 범위(`~`)는 한 덩어리: 줄바꿈이 범위 안(빈칸·물결 뒤)에서 일어나지 않는다. 한 줄에 안 들어가면 글자를 줄인다(줄 끝에 ~ 가 매달리지 않는다 — 코드가 검사)
- 전 값은 540 축소본에서도 읽히게: 하한 64px, 얇은 취소선(0.042em)의 높이를 숫자끼리 구분(8·9 등)이 가장 잘 남는 자리로 코드가 골라 검사한다. 취소선은 `~`·`-`·`:` 같은 기호의 잉크를 피하는 높이에서만 고르고(못 피하면 `strike-symbol` 실패), 한글이 있으면 글자 가운데 쪽(숫자 높이 30~55%)을 쓴다
- 종이·인쇄: 종이 결 세기가 영역마다 다르고(균일 필터 금지), 잉크 번짐·농도·눌림이 영역·요소마다 조금씩 다르며 포인트색 판이 1px 어긋난 자국이 아주 옅게 있다. 판 전체의 점·섬유·얼룩(결보다 크게 튀는 6~8000px 덩어리)은 모양 상관없이 지우고 540 축소본에서 다시 잰다. 고른 종이 결은 남긴다
- 5장: **색면을 뒤집은 장**(바탕 = 날짜색, 글자 = 종이색) — 큰 적용일 + 한 줄 + 문의 줄 + 브랜드. 날짜–한 줄 간격은 1장과 같은 규칙, 문의 줄이 많을수록 한 줄을 한 단계 작게(190·160·140px) 해서 아래가 빽빽하지 않다(한 줄–문의 사이 84px 이상). 밝은 바탕(노랑 등)은 잉크 농도 얼룩을 줄여 필터 티를 뺀다
- 쪽 표시는 숫자 대신 막대 5개(지금 장만 진하게) — 2~4장 큰 번호와 숫자 체계가 겹치지 않는다
- 판 디스펙클: **판 전체**에서 종이 판의 섬유 점·티끌·얼룩을 찾아 지운다(마침표처럼 읽히는 점 0, 여백의 티끌도 0). 종이 결은 그대로 둔다
- 날짜·번호 조판: 글자마다 잉크 상자를 재서 간격을 맞춘다(11 의 세리프가 붙지 않는다). 굵은 디스플레이 세리프라 가는 획이 없다. 코드가 픽셀로 재서 check.json 에 남긴다
원본 템플릿: https://duchamp.app/readymade/carousels/update-carousel (바탕이 된 이미지 템플릿: https://duchamp.app/readymade/images/update-change-list)
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "종이색, 글자색, 날짜색" HEX 3개. 비우면 #EFE9DD, #2A2622, #C24A2C. 날짜색은 5장 바탕이 되므로 종이색과 대비가 큰 색(대비 3 미만이면 코드가 글자색을 바탕으로 쓴다)
주제: (내 브랜드에 맞는 주제 하나) # 무엇이 바뀌는지. 예: 가을 영업 시간 변경
넣을 글자: (주제에 맞춰 분량 상한 안에서 장별 문구를 지어 넣는다) # 장마다 한 줄씩
# 1장: 큰 날짜 / 제목 날짜는 숫자와 점 5자 이내(예: 10.07), 제목 10자 이내(길면 두 줄)
# 2장: 항목 이름 / 전 / 후 [/ 메모] 이름 8자, 전·후 각 12자, 메모(선택) 20자 이내
# (후가 길면 빈칸·쉼표에서 두 줄로 나뉜다. ~ 범위 안에서는 끊지 않고 글자를 줄인다)
# 3장: 항목 이름 / 전 / 후 [/ 메모]
# 4장: 항목 이름 / 전 / 후 [/ 메모]
# 5장: 적용일 / 한 줄 / 문의 줄 1~3개 적용일은 날짜 규칙과 같음, 한 줄 10자, 문의 줄 각 20자 이내
출력: PNG만 # PNG만 | MP4까지
배경판: 자동 # 자동 | 내 사진: <경로> | 예시 판 | ChatGPT 판: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 글자 한도를 넘거나 항목·줄 수가 맞지 않으면 맞춰 달라고 한다. 바뀐 내용(시간·가격·메뉴)·연락처는 사용자가 준 것만 쓴다. 브랜드명·넣을 글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 번호(01·02…)·"전/후"·쪽 표시(막대 5개)는 코드가 붙인다.
- 2~4장 위 띠 왼쪽에는 1장 제목이 다시 나온다(브랜드 대신). 그래서 1장 제목에도 브랜드명을 넣지 않는다.
- **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/plate.(png|jpg) ← 글자·괘선 없는 면 종이 판 한 장(다섯 장이 같이 쓴다)
out/s1.png … out/s5.png out/check.json (out/s1.mp4 … out/s5.mp4 out/frames/*.png)
```
판은 장 수만큼 만들지 않는다. 한 장을 코드가 장마다 조금씩 다른 자리로 잘라 쓴다. 종이색은 코드가 맞춘다(판 색이 브랜드 종이색과 많이 다르면 판의 결만 빌려 종이색을 바꾼다).
## 3. 배경판 고르기 (`배경판` 값에 따라 하나)
판 조건: 두꺼운 무코팅 면 종이를 정면에서 화면 끝까지 꽉 차게 찍은 사진, 4:5 세로, 고른 종이 섬유 결, 옆에서 낮게 들어오는 부드러운 빛, **글자·선·자국·티끌·점 없음**(점은 글자 옆에서 마침표처럼 읽힌다 — 코드가 판 전체에서 한 번 더 지운다).
- **자동**: 이미지 생성 도구가 있으면(예: Codex 내장 image_gen) 아래 프롬프트로 한 장 생성 → 생성된 원본 파일을 `cp` 로 `public/plates/plate.png` 에 복사(편집·리샘플 금지). 이미지 생성 도구가 없으면(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` 등) — 사용자 크레딧이 드니 쓰기 전에 몇 장 만들지 말하고 승인받는다. 판 대조·렌더 검사·다시 만들기 규칙은 어느 경로든 같고, 어느 경로로 판을 만들었는지 보고에 쓴다. 셋 다 없으면 `예시 판` 으로 넘어가고 보고에 그 사실을 쓴다.
- **내 사진: <경로>** / **ChatGPT 판: <경로>**: 그 파일을 `public/plates/plate.<확장자>` 로 복사.
- **예시 판**: Duchamp 공개 예시 판(미색 면 종이)을 받는다.
```bash
mkdir -p public/plates
curl -fsSL https://duchamp.app/readymade/recipe-plates/update-carousel/plate.jpg -o public/plates/plate.jpg
```
판 생성 프롬프트(이미지 도구에 그대로; `{종이색}` 만 HEX 로 바꿔 넣는다):
```
A sheet of thick uncoated cotton card stock in colour {종이색}, photographed straight on and full-bleed, portrait 4:5. The paper fills the entire frame edge to edge — no paper edges, no table, no shadows of objects. Visible cotton fibres and an even fine tooth, clean surface with NO specks, dots, dust, hairs or inclusions, soft low raking light from the left, gentle vignette. Completely blank: absolutely NO text, letters, numbers, lines, rules, shapes, deboss marks, logos or watermarks. Fully opaque.
```
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"date": "<1장 큰 날짜 그대로>",
"title": "<1장 제목 그대로>",
"changes": [
{"name": "<2장 항목 이름>", "before": "<전>", "after": "<후>", "note": "<메모, 없으면 빈 문자열>"},
{"name": "<3장>", "before": "...", "after": "...", "note": ""},
{"name": "<4장>", "before": "...", "after": "...", "note": ""}
],
"last": {"date": "<5장 적용일>", "title": "<5장 한 줄>", "lines": ["<문의 줄>", "..."]},
"colors": {"paper": "<종이색>", "ink": "<글자색>", "accent": "<날짜색>"},
"plate": "plates/plate.<확장자>"
}
```
- 날짜는 폭에 꽉 차게(최대 600px), 1장 제목은 남는 높이 안에서 최대 230px 두 줄까지, 5장 한 줄은 최대 190px(문의 두 줄 160·세 줄 140), 후 값은 세 장 공통 크기(각 장이 들어갈 수 있는 크기 중 가장 작은 것, 메모 없는 장 최대 260px·있는 장 200px)로 코드가 정한다. 긴 글자는 넘치지 않게 코드가 줄이고, 너무 많이 줄여야 하면 검사 실패로 알린다.
- 1장에 항목 목록을 넣지 않는다(표지는 날짜와 제목만). 바뀐 항목은 2~4장 `changes` 에 하나씩.
## 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-serif-kr": "5.3.0"
},
"scripts": {
"postinstall": "node hf.mjs fonts"
}
}
```
`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]; };
// 판 분석(캔버스, sRGB): 평균 색·밝기 + 종이 섬유 = 고역통과(판 − 흐린 판 + 0.5)
const analysePlate = async (src: string) => {
const i = await loadImage(src);
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d', {willReadFrequently: true})!;
drawCover(g, i);
const a = g.getImageData(0, 0, W, H);
g.filter = 'blur(5px)'; g.drawImage(c, 0, 0); g.filter = 'none';
const b = g.getImageData(0, 0, W, H);
const sum = [0, 0, 0]; let lum = 0;
for (let k = 0; k < a.data.length; k += 4) {
sum[0] += a.data[k]; sum[1] += a.data[k + 1]; sum[2] += a.data[k + 2];
const ya = (0.2126 * a.data[k] + 0.7152 * a.data[k + 1] + 0.0722 * a.data[k + 2]) / 255;
const yb = (0.2126 * b.data[k] + 0.7152 * b.data[k + 1] + 0.0722 * b.data[k + 2]) / 255;
lum += ya;
const v = Math.max(0, Math.min(255, Math.round((0.5 + 3.2 * (ya - yb)) * 255)));
a.data[k] = a.data[k + 1] = a.data[k + 2] = v; a.data[k + 3] = 255;
}
g.putImageData(a, 0, 0);
const n = W * H;
return {mean: sum.map((s) => s / n), lum: lum / n, fiber: c.toDataURL('image/png')};
};
// 판 색이 브랜드 종이색과 다르면 tint: 판을 흑백으로 바꿔 평균 밝기를 0.5 로 맞추고 soft-light 로 얹는다(섬유·빛만 빌림)
const plateLook = (mean: number[], lum: number, paper: string): {mode: 'photo' | 'tint'; style: React.CSSProperties} => {
const p = hexRgb(paper);
const d = Math.sqrt(p.reduce((s, v, k) => s + (v - mean[k]) ** 2, 0)) / 255;
const base: React.CSSProperties = {position: 'absolute', width: W, height: H, objectFit: 'cover'};
if (d < 0.1) return {mode: 'photo', style: base};
return {mode: 'tint', style: {...base, filter: `grayscale(1) brightness(${(0.5 / Math.max(0.03, lum)).toFixed(3)}) contrast(1.6)`, mixBlendMode: 'soft-light'}};
};
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'); };
// ── 큰 숫자 조판(2회차): 글자마다 실제 잉크 상자를 재서 잉크 사이 간격을 맞춘다 → 숫자·쉼표·단위가 서로 닿지 않고 간격이 고르다 ──
// 원점 = 기준선 왼쪽 끝(DOM 글자 상자 왼쪽과 같다). l·r = 원점에서 잉크 왼/오른 끝, t = 기준선 위 높이, b = 기준선 아래 깊이(px)
type Ink = {l: number; r: number; t: number; b: number};
type GlyphIn = {c: string; family: string; weight: number; size: number; stroke?: number; role?: 'digit' | 'mark' | 'unit'; stretch?: string};
type Placed = GlyphIn & {x: number; ink: Ink; asc: number; desc: number};
const fontOf = (g: GlyphIn) => `${g.weight} ${g.stretch ?? 'normal'} ${g.size}px "${g.family}"`;
const setFont = (x: CanvasRenderingContext2D, g: GlyphIn) => { x.font = fontOf(g); x.fontStretch = (g.stretch ?? 'normal') as CanvasFontStretch; };
const rasterCache = new Map<string, {m: Uint8Array; S: number; ox: number; oy: number; ink: Ink; box: [number, number, number, number]}>();
const rasterGlyph = (g: GlyphIn) => {
const key = fontOf(g) + '|' + (g.stroke ?? 0) + '|' + g.c;
const hit = rasterCache.get(key);
if (hit) return hit;
const S = Math.ceil(g.size * 2.4), ox = Math.round(g.size * 0.6), oy = Math.round(g.size * 1.6);
const c = document.createElement('canvas'); c.width = S; c.height = S;
const x = c.getContext('2d', {willReadFrequently: true})!;
setFont(x, g); x.textBaseline = 'alphabetic'; x.fillStyle = '#000'; x.strokeStyle = '#000';
x.fillText(g.c, ox, oy);
if (g.stroke) { x.lineWidth = g.stroke; x.lineJoin = 'round'; x.strokeText(g.c, ox, oy); }
const d = x.getImageData(0, 0, S, S).data, m = new Uint8Array(S * S);
let x0 = S, x1 = -1, y0 = S, y1 = -1;
for (let y = 0; y < S; y++) for (let k = 0; k < S; k++) if (d[(y * S + k) * 4 + 3] > 96) { m[y * S + k] = 1; if (k < x0) x0 = k; if (k > x1) x1 = k; if (y < y0) y0 = y; if (y > y1) y1 = y; }
const blank = x1 < 0;
const adv = x.measureText(g.c).width;
const ink: Ink = blank ? {l: 0, r: adv, t: 0, b: 0} : {l: x0 - ox, r: x1 + 1 - ox, t: oy - y0, b: y1 + 1 - oy};
const out = {m, S, ox, oy, ink, box: [x0, y0, x1, y1] as [number, number, number, number]};
rasterCache.set(key, out);
return out;
};
const fontBox = (g: GlyphIn) => {
const x = document.createElement('canvas').getContext('2d')!;
setFont(x, g);
const m = x.measureText(g.c || '0');
return {asc: m.fontBoundingBoxAscent, desc: m.fontBoundingBoxDescent};
};
// gap(a, b) = 두 글자 잉크 사이 간격(px). 결과의 width = 첫 잉크 왼끝 ~ 마지막 잉크 오른끝
const setInk = (gs: GlyphIn[], gap: (a: GlyphIn, b: GlyphIn) => number) => {
const glyphs: Placed[] = [];
let right = 0;
gs.forEach((g, i) => {
const {ink} = rasterGlyph(g);
const x = i === 0 ? -ink.l : right + gap(gs[i - 1], g) - ink.l;
glyphs.push({...g, x, ink, ...fontBox(g)});
right = x + ink.r;
});
return {glyphs, width: right};
};
// 이웃한 두 잉크 상자 사이 실제 간격(px). 음수 = 겹침
const inkGaps = (gs: Placed[]) => gs.slice(1).map((g, i) => ({pair: gs[i].c + g.c, px: Math.round((g.x + g.ink.l - (gs[i].x + gs[i].ink.r)) * 10) / 10}));
// 가는 획 검사: 최소 굵기(px) 정사각 창으로 열림(침식→팽창)을 하면 그보다 가는 획이 사라진다.
// 사라진 조각 하나의 길이가 0.08em 을 넘으면 "가는 획"(예: 디돈 4 의 사선, 쉼표 꼬리) → 작게 보면 1·마침표로 읽힐 수 있다
// 최소 굵기는 글자마다 max(6px, 0.024em) — 기호 단위(%)처럼 작게 찍는 글자는 제 크기 기준
const thinStrokes = (gs: GlyphIn[], minPx: (g: GlyphIn) => number) => {
const out: {c: string; em: number}[] = [];
const seenKey = new Set<string>();
for (const g of gs) {
if (!/[0-9,.%]/.test(g.c)) continue;
const key = fontOf(g) + g.c; if (seenKey.has(key)) continue; seenKey.add(key);
const k = Math.max(3, Math.round(minPx(g)) | 1), r = (k - 1) / 2;
const {m, S, box: [x0, y0, x1, y1]} = rasterGlyph({...g, stroke: 0});
if (x1 < 0) continue;
const X0 = Math.max(0, x0 - k), X1 = Math.min(S - 1, x1 + k), Y0 = Math.max(0, y0 - k), Y1 = Math.min(S - 1, y1 + k);
const pass = (src: Uint8Array, erode: boolean, horiz: boolean) => {
const dst = new Uint8Array(S * S);
for (let y = Y0; y <= Y1; y++) for (let x = X0; x <= X1; x++) {
let v = erode ? 1 : 0;
for (let t = -r; t <= r; t++) {
const xx = horiz ? x + t : x, yy = horiz ? y : y + t;
const q = xx < 0 || yy < 0 || xx >= S || yy >= S ? 0 : src[yy * S + xx];
if (erode ? !q : q) { v = erode ? 0 : 1; break; }
}
dst[y * S + x] = v;
}
return dst;
};
const opened = pass(pass(pass(pass(m, true, true), true, false), false, true), false, false);
const seen = new Uint8Array(S * S);
let worst = 0;
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const i = y * S + x;
if (!m[i] || opened[i] || seen[i]) continue;
const st = [i]; seen[i] = 1; let a = S, b = -1, c = S, d = -1;
while (st.length) {
const j = st.pop()!, jx = j % S, jy = (j / S) | 0;
if (jx < a) a = jx; if (jx > b) b = jx; if (jy < c) c = jy; if (jy > d) d = jy;
for (const q of [j - 1, j + 1, j - S, j + S]) if (m[q] && !opened[q] && !seen[q]) { seen[q] = 1; st.push(q); }
}
worst = Math.max(worst, Math.max(b - a + 1, d - c + 1));
}
if (worst / g.size > 0.08) out.push({c: g.c, em: +(worst / g.size).toFixed(3)});
}
return out;
};
// 단위(한글 등)는 잉크 아랫끝을 숫자 잉크 아랫끝에 맞춘다(dy = 기준선을 내리는 양 px). 모든 장이 같은 규칙
// 숫자 아랫선 = 기준선에 앉는 숫자들의 잉크 아랫끝(서체에 따라 4·7·9 처럼 기준선 아래로 내려가는 숫자가 있다 — Roboto Serif 의 4 는 0.077em. 그런 숫자는 빼고 잰다)
const sitBottom = (bs: number[], size: number) => { const sit = bs.filter((b) => b <= size * 0.03); return sit.length ? Math.max(...sit) : 0; };
const alignUnits = (gs: Placed[]) => {
const ds = gs.filter((g) => g.role === 'digit');
const bottom = ds.length ? sitBottom(ds.map((g) => g.ink.b), ds[0].size) : 0;
return gs.map((g) => (g.role === 'unit' ? {...g, dy: bottom - g.ink.b} : {...g, dy: 0}));
};
type InkRow = (Placed & {dy: number})[];
// 큰 숫자 줄 검사: 완성된 줄을 캔버스 한 장에 실제 자리대로 그린 뒤 픽셀로 잰다(조판 계산과 따로)
// gap: 이웃 글자 잉크 사이 가장 좁은 간격 ≥ 0.025em · thin: 가는 획 · unit: 단위 잉크 아랫끝과 숫자 잉크 아랫끝 차이 ≤ 1.5px
const numberCheck = (name: string, gs: InkRow, digitSize: number) => {
const pad = Math.ceil(digitSize * 0.6), width = Math.ceil(Math.max(...gs.map((g) => g.x + g.ink.r)) + 2 * pad);
const top = Math.ceil(Math.max(...gs.map((g) => g.ink.t - g.dy)) + pad), H2 = Math.ceil(top + Math.max(...gs.map((g) => g.ink.b + g.dy)) + pad);
const c = document.createElement('canvas'); c.width = width; c.height = H2;
const x = c.getContext('2d', {willReadFrequently: true})!;
gs.forEach((g) => { setFont(x, g); x.fillStyle = '#000'; x.fillText(g.c, pad + g.x, top + g.dy); if (g.stroke) { x.lineWidth = g.stroke; x.lineJoin = 'round'; x.strokeText(g.c, pad + g.x, top + g.dy); } });
const d = x.getImageData(0, 0, width, H2).data;
const ink = (px: number, py: number) => d[(py * width + px) * 4 + 3] > 96;
// 글자마다 창 = 예상 잉크 상자의 이웃과의 중간점까지
const edges = gs.map((g) => [pad + g.x + g.ink.l, pad + g.x + g.ink.r]);
const win = edges.map(([a, b], i) => [i === 0 ? 0 : Math.floor((edges[i - 1][1] + a) / 2), i === gs.length - 1 ? width - 1 : Math.ceil((b + edges[i + 1][0]) / 2)]);
const meas = win.map(([a, b]) => {
let l = -1, r = -1, bot = -1;
for (let px = Math.max(0, a); px <= Math.min(width - 1, b); px++) for (let py = 0; py < H2; py++) if (ink(px, py)) { if (l < 0) l = px; r = px; if (py > bot) bot = py; }
return {l, r, bot};
});
const gaps = gs.slice(1).map((g, i) => ({pair: gs[i].c + g.c, px: meas[i + 1].l - meas[i].r - 1}));
const minGap = gaps.length ? Math.min(...gaps.map((q) => q.px)) : null;
// 숫자 창에는 이웃 쉼표 꼬리가 섞일 수 있어, 숫자 글자 하나만 따로 그려 잰 아랫끝을 쓴다(그 숫자의 실제 위치 기준)
const digitBots = gs.filter((g) => g.role === 'digit').map((g) => g.ink.b + g.dy);
const digitBottom = top + (digitBots.length ? sitBottom(digitBots, digitSize) : 0) - 1;
const unitErr = gs.some((g) => g.role === 'unit') ? Math.max(...meas.filter((_, i) => gs[i].role === 'unit').map((q) => Math.abs(q.bot - digitBottom))) : 0;
const minStroke = Math.max(6, digitSize * 0.024);
const thin = thinStrokes(gs, (g) => Math.max(6, g.size * 0.024));
const over: string[] = [];
if (minGap !== null && minGap < digitSize * 0.025) over.push(name + '-gap');
if (thin.length) over.push(name + '-thin');
if (unitErr > 1.5) over.push(name + '-unit-baseline');
const glyphBottoms = gs.map((g, i) => ({c: g.c, role: g.role, inkBottomPx: meas[i].bot - top}));
return {over, report: {text: gs.map((g) => g.c).join(''), glyphBottoms, minGapPx: minGap, minGapEm: minGap === null ? null : +(minGap / digitSize).toFixed(3), gaps, minStrokePx: +minStroke.toFixed(1), thin, unitBaselineErrPx: unitErr}};
};
// 조판된 글자 하나를 DOM 에 놓는다(글자 상자 줄 높이 = 폰트 ascent+descent → 위 = 기준선 − ascent)
const glyphStyle = (g: Placed & {dy: number}, left: number, base: number, fix = 0): React.CSSProperties => ({
position: 'absolute', left: left + g.x, top: base + g.dy - g.asc + fix, lineHeight: `${g.asc + g.desc}px`, height: g.asc + g.desc,
fontFamily: `"${g.family}"`, fontWeight: g.weight, fontStretch: g.stretch ?? 'normal', fontSize: g.size, whiteSpace: 'pre', fontOpticalSizing: 'none',
});
// 화면 기준선: 한글 서체는 줄 상자 ascent 가 캔버스 값과 달라 기준선이 어긋난다(2회차 Codex 실행에서 단위 '개'가 14px 내려앉은 것을 실측).
// 그래서 글자마다 기준선 표식(<BL/>, 폭·높이 0 인 inline-block = 아랫변이 기준선)을 넣고, 화면에서 실제 기준선을 재서 보정한다(변형 무시 offsetTop).
const glyphData = (g: Placed & {dy: number}, base: number) => JSON.stringify({c: g.c, family: g.family, weight: g.weight, size: g.size, stretch: g.stretch, b: base + g.dy});
const BL: React.FC = () => <i data-bl style={{display: 'inline-block', width: 0, height: 0}} />;
const baselineErrs = () => [...document.querySelectorAll<HTMLElement>('[data-glyph]')].map((el) => {
const m = el.querySelector<HTMLElement>('[data-bl]');
return m ? Math.round((JSON.parse(el.dataset.glyph!).b - (el.offsetTop + m.offsetTop)) * 10) / 10 : 0;
});
// 보정 → 다시 재서 남은 오차(px)를 돌려준다. fix 는 글자 순서대로 top 에 더한다
const fixBaselines = async (setFix: (f: number[]) => void) => {
const e1 = baselineErrs();
setFix(e1);
await settle();
const e2 = baselineErrs();
return {before: Math.max(0, ...e1.map(Math.abs)), after: Math.max(0, ...e2.map(Math.abs))};
};
// 화면(DOM)의 글자 폭과 캔버스 조판 폭이 같은지(다르면 조판 검사가 화면과 어긋난다) — [data-glyph] 글자마다 비교, 최대 차이 px
const domMatch = () => {
const x = document.createElement('canvas').getContext('2d')!;
let worst = 0;
document.querySelectorAll<HTMLElement>('[data-glyph]').forEach((el) => {
const g = JSON.parse(el.dataset.glyph!) as GlyphIn;
setFont(x, g);
worst = Math.max(worst, Math.abs(el.getBoundingClientRect().width - x.measureText(el.textContent ?? '').width));
});
return Math.round(worst * 10) / 10;
};
// ── 글자 뒤 디스펙클(3회차): 종이 판의 섬유 점·티끌이 글자 줄에 걸리면 마침표·쉼표로 읽힌다(2회차 검수: "두레 국.밥").
// 판을 장의 크롭대로 캔버스에 그린 뒤, 글자 상자(+여백 30px) 안에서 "점"만 찾아 주변 바탕으로 메운다. 종이 결(고른 섬유 무늬)은 그대로 둔다
// (결까지 평평하게 누르면 글자 뒤에 매끈한 네모가 드러난다 — 실측 후 버림). 메운 판으로 섬유 overlay 를 다시 만들고, 같은 기준으로 남은 점을 다시 센다(목표 0).
type Rect = {x: number; y: number; w: number; h: number};
// 화면에서 [data-t] 글자 요소의 상자(컴포지션 좌표 px)
const textRects = (root: HTMLElement | null): Rect[] => {
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}; }).filter((r) => r.w > 1 && r.h > 1);
};
// 점 찾기: 마스크 안(m > 0.5)에서 판과 바탕(흐린 판)의 차이(화면 기준: tint 면 밝기, photo 면 채널 최대)가 thr 를 넘는 픽셀 덩어리 중
// 작은 것(넓이 6~600px = 지름 약 3px 이상, 긴 변 ≤ 40px) = 마침표·쉼표·따옴표로 읽힐 수 있는 점과 짧은 섬유 조각(모양은 따지지 않는다 —
// 3회차 실행에서 둥근 것만 지웠더니 '일요일' 끝의 짧은 갈색 사선 섬유가 따옴표처럼 남았다). 결(고른 무늬)은 기준(σ×3.5)을 넘지 않아 그대로 남는다
const findSpecks = (a: Uint8ClampedArray, b: Uint8ClampedArray, m: Float32Array, G: number, gray: boolean, thr: number | null) => {
const dif = new Float32Array(W * H);
let ss = 0, nn = 0;
for (let i = 0; i < W * H; i++) {
if (m[i] <= 0.5) continue;
const k = i * 4;
const d = gray ? Math.abs(0.2126 * (a[k] - b[k]) + 0.7152 * (a[k + 1] - b[k + 1]) + 0.0722 * (a[k + 2] - b[k + 2])) : Math.max(Math.abs(a[k] - b[k]), Math.abs(a[k + 1] - b[k + 1]), Math.abs(a[k + 2] - b[k + 2]));
dif[i] = (d / 255) * G; ss += dif[i] * dif[i]; nn++;
}
const t = thr ?? Math.max(0.07, 3.5 * Math.sqrt(ss / Math.max(1, nn))); // 결의 세기(σ)의 3.5배 이상 튀는 것만
const seen = new Uint8Array(W * H), pix: number[] = [];
let count = 0;
for (let i = 0; i < W * H; i++) {
if (seen[i] || dif[i] <= t) continue;
const st = [i], blob: number[] = []; seen[i] = 1;
let x0 = W, x1 = 0, y0 = H, y1 = 0;
while (st.length) {
const j = st.pop()!; blob.push(j); const x = j % W, y = (j / W) | 0;
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
for (const q of [x > 0 ? j - 1 : -1, x < W - 1 ? j + 1 : -1, j - W, j + W]) if (q >= 0 && q < W * H && !seen[q] && dif[q] > t) { seen[q] = 1; st.push(q); }
}
// 4회차: 모양·길이 조건 없음(점이든 긴 섬유든 글자 둘레 반경 안이면 지운다). 넓이 6px 미만(540 에서 1px 미만)만 둔다
if (blob.length >= 6 && blob.length <= 8000) { count++; pix.push(...blob); }
}
return {count, pix, thr: t};
};
// gain = 화면에 나갈 때 판 밝기 차이가 몇 배로 커지는지(tint 면 brightness×contrast), gray = tint(흑백으로 얹음)
// spots = 540 축소본 검사(specks540)가 찾은 자리(1080 좌표) — 반지름 8px 를 함께 메운다(cleanPlateFor540 이 되풀이)
const cleanPlate = async (src: string, crop: Crop, rects: Rect[], gain = 1, gray = false, seed = 1, spots: {x: number; y: number}[] = [], opt = {pad: 36, feather: 10, grow: 2}) => {
const G = Math.max(1, gain);
const i = await loadImage(src);
const mk = () => { const c = document.createElement('canvas'); c.width = W; c.height = H; return [c, c.getContext('2d', {willReadFrequently: true})!] as const; };
const [c, g] = mk();
g.translate(crop.fx * W, crop.fy * H); g.scale(crop.s, crop.s); g.translate(-crop.fx * W, -crop.fy * H);
drawCover(g, i); g.setTransform(1, 0, 0, 1, 0, 0);
const a = g.getImageData(0, 0, W, H);
const blurOf = (d: ImageData) => { const [cc, gg] = mk(); gg.putImageData(d, 0, 0); const [, g2] = mk(); g2.filter = 'blur(7px)'; g2.drawImage(cc, 0, 0); return g2.getImageData(0, 0, W, H); };
const b = blurOf(a);
// 마스크: 글자 상자 + pad, 가장자리 feather
const [, gm] = mk(); gm.fillStyle = '#000'; gm.fillRect(0, 0, W, H); gm.filter = `blur(${opt.feather}px)`; gm.fillStyle = '#fff';
rects.forEach((r) => gm.fillRect(r.x - opt.pad, r.y - opt.pad, r.w + 2 * opt.pad, r.h + 2 * opt.pad));
const md = gm.getImageData(0, 0, W, H).data;
const m = new Float32Array(W * H);
for (let p = 0; p < W * H; p++) m[p] = md[p * 4] / 255;
const s1 = findSpecks(a.data, b.data, m, G, gray, null);
// 점 픽셀(+2px)을 표시 → 그 자리를 뺀 바탕을 다시 만들어(점이 바탕에 번져 남지 않게) 점 자리를 바탕 값으로 메운다.
// 메운 뒤 바탕이 바뀌어 새로 튀는 점이 생길 수 있어 같은 기준으로 최대 4번 되풀이한다
let sp = s1, bb = b, passes = 0;
while ((sp.count > 0 || (passes === 0 && spots.length > 0)) && passes < 4) {
passes++;
const hit = new Uint8Array(W * H);
if (passes === 1) for (const q of spots) for (let dy = -8; dy <= 8; dy++) for (let dx = -8; dx <= 8; dx++) { const xx = q.x + dx, yy = q.y + dy; if (dx * dx + dy * dy <= 64 && xx >= 0 && yy >= 0 && xx < W && yy < H) hit[yy * W + xx] = 1; }
for (const j of sp.pix) { const x = j % W, y = (j / W) | 0; for (let dy = -opt.grow; dy <= opt.grow; dy++) for (let dx = -opt.grow; dx <= opt.grow; dx++) { const xx = x + dx, yy = y + dy; if (xx >= 0 && yy >= 0 && xx < W && yy < H) hit[yy * W + xx] = 1; } }
const fillD = new ImageData(new Uint8ClampedArray(a.data), W, H);
for (let p = 0; p < W * H; p++) if (hit[p]) { const k = p * 4; fillD.data[k] = bb.data[k]; fillD.data[k + 1] = bb.data[k + 1]; fillD.data[k + 2] = bb.data[k + 2]; }
const b2 = blurOf(fillD);
for (let p = 0; p < W * H; p++) if (hit[p]) { const k = p * 4; a.data[k] = b2.data[k]; a.data[k + 1] = b2.data[k + 1]; a.data[k + 2] = b2.data[k + 2]; }
bb = blurOf(a);
sp = findSpecks(a.data, bb.data, m, G, gray, s1.thr);
}
g.putImageData(a, 0, 0);
// 섬유 overlay = 고역통과(메운 판 − 흐린 판 + 0.5)
const [cf, gf] = mk(); gf.filter = 'blur(5px)'; gf.drawImage(c, 0, 0); const fb = gf.getImageData(0, 0, W, H);
const f = gf.createImageData(W, H);
const L = (d: Uint8ClampedArray, k: number) => (0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]) / 255;
// 4회차: 섬유 세기를 영역마다 다르게(낮은 주파수 장 0.35~1.45배) — 한 장 전체에 같은 필터를 씌운 것처럼 보이지 않게
const rf = regionField(seed);
for (let p = 0; p < W * H; p++) {
const k = p * 4, v = 0.5 + 3.2 * (L(a.data, k) - L(fb.data, k)) * (0.35 + 1.1 * rf(p % W, (p / W) | 0));
f.data[k] = f.data[k + 1] = f.data[k + 2] = Math.max(0, Math.min(255, Math.round(v * 255))); f.data[k + 3] = 255;
}
gf.filter = 'none'; gf.putImageData(f, 0, 0);
const texture = textureVariation(f.data);
// 남은 점 = 마지막 회차에 같은 기준(처음 정한 thr)으로 다시 센 수
return {plate: c.toDataURL('image/png'), fiber: cf.toDataURL('image/png'), specks: {before: s1.count, after: sp.count, passes, thr: +s1.thr.toFixed(3), rects: rects.length, padPx: opt.pad}, texture};
};
// ── 4회차: 영역별 변화 · 인쇄 단서 · 540 축소본 점 검사 ──
// 부드러운 값 노이즈 장(0~1): 굵은 격자 3×4 (0.7) + 고운 격자 8×10 (0.3), smoothstep 보간. seed 로 장마다 다른 자리
const hash3 = (a: number, b: number, s: number) => { let h = (Math.imul(a, 374761393) + Math.imul(b, 668265263) + Math.imul(s, 1442695041)) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967295; };
const regionField = (seed: number) => {
const oct = (gx: number, gy: number, s: number) => (x: number, y: number) => {
const u = (x / W) * gx, v = (y / H) * gy, i = Math.floor(u), j = Math.floor(v);
const sm = (t: number) => t * t * (3 - 2 * t), a = sm(u - i), b = sm(v - j);
return (hash3(i, j, s) * (1 - a) + hash3(i + 1, j, s) * a) * (1 - b) + (hash3(i, j + 1, s) * (1 - a) + hash3(i + 1, j + 1, s) * a) * b;
};
const o1 = oct(3, 4, seed), o2 = oct(8, 10, seed + 17);
return (x: number, y: number) => Math.min(1, Math.max(0, (o1(x, y) * 0.7 + o2(x, y) * 0.3 - 0.5) * 1.8 + 0.5));
};
// 질감 변화 검사: 섬유 overlay 의 세기(|v − 0.5| 평균)를 6×8 칸으로 재서 변동계수(표준편차/평균). 0.2 미만 = 한 장에 고르게 씌운 필터처럼 보인다
const textureVariation = (d: Uint8ClampedArray) => {
const gx = 6, gy = 8, sum = new Float64Array(gx * gy), cnt = new Float64Array(gx * gy);
for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) { const c = Math.min(gy - 1, ((y / H) * gy) | 0) * gx + Math.min(gx - 1, ((x / W) * gx) | 0); sum[c] += Math.abs(d[(y * W + x) * 4] / 255 - 0.5); cnt[c]++; }
const m = [...sum].map((v, i) => v / cnt[i]), mean = m.reduce((a, b) => a + b, 0) / m.length;
const sd = Math.sqrt(m.reduce((a, b) => a + (b - mean) ** 2, 0) / m.length);
return {cv: +(sd / Math.max(1e-9, mean)).toFixed(3), min: +Math.min(...m).toFixed(4), max: +Math.max(...m).toFixed(4)};
};
// 인쇄 단서 필터(id = print): 실제 인쇄물처럼 아주 절제해서, 영역마다 다르게
// ① 가장자리 거칠기(변위 1.2px) ② 잉크 번짐 = 살짝 퍼진 잉크를 낮은 주파수 마스크 안에서만 더함(어떤 곳은 번지고 어떤 곳은 깨끗)
// ③ 잉크 농도 = 고운 얼룩 × 낮은 주파수 농도 얼룩(0.8~1.0). 눌림(위 그림자·아래 빛)과 판 어긋남(포인트색 1px 유령)은 요소 스타일(pressShadow·misreg)
// density(6회차, 기본 1): 잉크 농도 얼룩 세기 0~1. 밝은 바탕에 어두운 잉크(예: 노랑 반전 장)는 농도 차이가 획 안의 얼룩으로 크게 보여 필터 티가 난다 → 낮춰 쓴다
const PrintDefs: React.FC<{seed: number; spread?: number; density?: number}> = ({seed, spread = 0.6, density = 1}) => (
<svg width={0} height={0} style={{position: 'absolute'}}>
<filter id="print" x="-5%" y="-5%" width="110%" height="110%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves={1} seed={seed} result="e" />
<feDisplacementMap in="SourceGraphic" in2="e" scale={1.2} xChannelSelector="R" yChannelSelector="G" result="rough" />
<feGaussianBlur in="rough" stdDeviation={spread} result="bl" />
<feComponentTransfer in="bl" result="spread"><feFuncA type="linear" slope={1.3} /></feComponentTransfer>
<feTurbulence type="fractalNoise" baseFrequency="0.006" numOctaves={2} seed={seed + 3} result="m" />
<feColorMatrix in="m" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 0 -1.9" result="mask" />
<feComposite in="spread" in2="mask" operator="in" result="spreadM" />
<feMerge result="inked"><feMergeNode in="rough" /><feMergeNode in="spreadM" /></feMerge>
<feTurbulence type="fractalNoise" baseFrequency="0.6" numOctaves={3} seed={seed + 7} result="n" />
<feColorMatrix in="n" type="matrix" values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${+(0.4 * density).toFixed(3)} 0 0 0 ${+(1 - 0.2 * density).toFixed(3)}`} result="na" />
<feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves={2} seed={seed + 11} result="d" />
<feColorMatrix in="d" type="matrix" values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${+(0.3 * density).toFixed(3)} 0 0 0 ${+(1 - 0.26 * density).toFixed(3)}`} result="da" />
<feComposite in="na" in2="da" operator="in" result="dens" />
<feComposite in="inked" in2="dens" operator="in" />
</filter>
{/* 망점 막대용(id = printDots): 번짐 없이 거칠기·농도만 — 번짐을 넣으면 6px 망점이 뭉개져 회색 얼룩이 된다(4회차 try2 에서 확인) */}
<filter id="printDots" x="-5%" y="-5%" width="110%" height="110%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves={1} seed={seed} result="e" />
<feDisplacementMap in="SourceGraphic" in2="e" scale={1} xChannelSelector="R" yChannelSelector="G" result="rough" />
<feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves={2} seed={seed + 11} result="d" />
<feColorMatrix in="d" type="matrix" values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${+(0.3 * density).toFixed(3)} 0 0 0 ${+(1 - 0.26 * density).toFixed(3)}`} result="da" />
<feComposite in="rough" in2="da" operator="in" />
</filter>
</svg>
);
// 눌림 세기: 요소마다 0.7~1.3배(같은 판에서도 고르게 눌리지 않는다). k = 요소 번호
const pressK = (seed: number, k: number) => 0.7 + 0.6 * hash3(k, 7, seed);
// 판 어긋남: 포인트색(두 번째 판) 요소 밑에 글자색 1px 어긋난 유령(투명도 0.14) — 두 판이 1px 어긋나 찍힌 자국. 방향은 장마다 조금 다르다
const misreg = (ink: string, seed: number) => { const a = hash3(seed, 3, 5) * Math.PI * 2; return `drop-shadow(${Math.cos(a).toFixed(2)}px ${Math.sin(a).toFixed(2)}px 0 ${rgba(ink, 0.14)})`; };
// 종이 얼룩: 아주 낮은 주파수 밝기 차이(soft-light) — 장마다 seed 가 달라 얼룩 자리가 다르다
const PaperMottle: React.FC<{seed: number; opacity: number}> = ({seed, opacity}) => (
<svg width={W} height={H} style={{position: 'absolute', inset: 0, mixBlendMode: 'soft-light', opacity, pointerEvents: 'none'}}>
<filter id={'mottle' + seed} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.0045" numOctaves={3} seed={seed} />
<feColorMatrix type="matrix" values="0.4 0 0 0 0.3 0.4 0 0 0 0.3 0.4 0 0 0 0.3 0 0 0 0 1" />
</filter>
<rect width="100%" height="100%" filter={`url(#mottle${seed})`} />
</svg>
);
// 540 축소본 점 검사: 화면에 나가는 바탕(바탕색 + 판(photo 그대로 / tint 흑백 soft-light) + 섬유 overlay)을 캔버스에 같은 방식으로 합성해
// 피드 크기(540×675)로 줄인 뒤, 글자 상자 + 18px(=1080 에서 36px) 안에서 주변(3px 흐림)보다 튀는 덩어리(넓이 2px 이상, 모양 무관)를 찾는다.
// 덩어리 = 결 세기(σ)의 3배를 넘는 이웃 픽셀, 점으로 셈 = 가장 튀는 픽셀이 max(0.22, 8σ) 이상(밝기 차). 3회차 '11곳' 위 섬유는 0.31, 종이 결의 최대는 0.19 였다. 목표 0
const specks540 = async (fill: string, plate: string, fiber: string, look: {mode: 'photo' | 'tint'; style: React.CSSProperties}, fiberOpacity: number, rects: Rect[], pad = 36) => {
const [pi, fi] = await Promise.all([loadImage(plate), loadImage(fiber)]);
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d', {willReadFrequently: true})!;
g.fillStyle = fill; g.fillRect(0, 0, W, H);
if (look.mode === 'tint') { g.filter = String(look.style.filter); g.globalCompositeOperation = 'soft-light'; }
g.drawImage(pi, 0, 0, W, H); g.filter = 'none';
g.globalCompositeOperation = 'overlay'; g.globalAlpha = fiberOpacity; g.drawImage(fi, 0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
const w = W / 2, h = H / 2;
const s = document.createElement('canvas'); s.width = w; s.height = h;
const gs = s.getContext('2d', {willReadFrequently: true})!; gs.imageSmoothingQuality = 'high'; gs.drawImage(c, 0, 0, w, h);
const a = gs.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(3px)'; gb.drawImage(s, 0, 0);
const b = gb.getImageData(0, 0, w, h).data;
const m = new Uint8Array(w * h);
rects.forEach((r) => { for (let y = Math.max(0, Math.floor((r.y - pad) / 2)); y < Math.min(h, Math.ceil((r.y + r.h + pad) / 2)); y++) for (let x = Math.max(0, Math.floor((r.x - pad) / 2)); x < Math.min(w, Math.ceil((r.x + r.w + pad) / 2)); x++) m[y * w + x] = 1; });
const L = (d: Uint8ClampedArray, k: number) => (0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]) / 255;
const dif = new Float32Array(w * h); let ss = 0, nn = 0;
for (let i = 0; i < w * h; i++) if (m[i]) { dif[i] = Math.abs(L(a, i * 4) - L(b, i * 4)); ss += dif[i] ** 2; nn++; }
const sig = Math.sqrt(ss / Math.max(1, nn)), thr = Math.max(0.04, 3 * sig), hit = Math.max(0.22, 8 * sig);
const seen = new Uint8Array(w * h), found: {x: number; y: number; px: number; c: number}[] = [];
let peak = 0;
for (let i = 0; i < w * h; i++) {
if (!m[i] || seen[i] || dif[i] <= thr) continue;
const st = [i]; seen[i] = 1; let n = 0, sx = 0, sy = 0, mc = 0;
while (st.length) { const j = st.pop()!; n++; sx += j % w; sy += (j / w) | 0; mc = Math.max(mc, dif[j]); const x = j % w; for (const q of [x > 0 ? j - 1 : -1, x < w - 1 ? j + 1 : -1, j - w, j + w]) if (q >= 0 && q < w * h && m[q] && !seen[q] && dif[q] > thr) { seen[q] = 1; st.push(q); } }
if (n >= 2) { peak = Math.max(peak, mc); if (mc >= hit) found.push({x: Math.round((sx / n) * 2), y: Math.round((sy / n) * 2), px: n, c: +mc.toFixed(3)}); }
}
return {count: found.length, hit: +hit.toFixed(3), peak: +peak.toFixed(3), at: found.slice(0, 8), all: found.map((q) => ({x: q.x, y: q.y}))}; // peak = 남은 결 중 가장 튀는 값
};
// 판 정리 + 540 축소본 검사를 함께: 검사에 걸린 자리를 판 정리에 다시 넘겨 메우고 다시 잰다(최대 3번). 결과의 specks540.count 가 0 이어야 통과
const cleanPlateFor540 = async (src: string, crop: Crop, rects: Rect[], gain: number, look: ReturnType<typeof plateLook>, fill: string, fiberOpacity: number, seed: number) => {
const spots: {x: number; y: number}[] = [];
let cp = await cleanPlate(src, crop, rects, gain, look.mode === 'tint', seed);
let s5 = await specks540(fill, cp.plate, cp.fiber, look, fiberOpacity, rects);
let rounds = 0;
while (s5.count > 0 && rounds < 3) {
rounds++;
spots.push(...s5.all);
cp = await cleanPlate(src, crop, rects, gain, look.mode === 'tint', seed, spots);
s5 = await specks540(fill, cp.plate, cp.fiber, look, fiberOpacity, rects);
}
const {all, ...s540} = s5;
return {...cp, specks540: {...s540, rounds, filled: spots.length}};
};
// 줄 상자 안 한글 잉크 자리: CSS 는 줄 높이 lh 의 가운데에 글꼴 상자(ascent+descent)를 놓는다 → 잉크 위·아래 = 줄 위 + 반여백 + ascent ∓ 실제 잉크
const inkBox = (text: string, font: string, size: number, lh: number) => {
const x = document.createElement('canvas').getContext('2d')!; x.font = font;
const m = x.measureText(text || '가'), fa = m.fontBoundingBoxAscent, fd = m.fontBoundingBoxDescent;
const base = (lh - (fa + fd)) / 2 + fa;
return {top: base - m.actualBoundingBoxAscent, bottom: base + m.actualBoundingBoxDescent, h: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent, size};
};
// ── Duchamp 코드 레시피 · update-carousel (5장 · 장마다 C 짧게: 등장 1.4s + 분위기 4s) ──
// 1장 = 활판 표지(큰 날짜 + 제목만) · 2~4장 = 바뀐 항목 하나씩(큰 번호 + 이름, 전(줄 그음) → 후 크게) · 5장 = 색면을 뒤집은 적용일 + 한 줄 + 문의
// 3회차: 빈 칸은 레이아웃이 흡수(메모 없는 장 = 후 값이 1~2줄로 커짐, 5장 문의 줄 수만큼 한 줄·문의 크기 조정, 1장 날짜–제목 간격 고정),
// 글자 뒤 판 디스펙클(cleanPlate), 쪽 표시는 숫자 대신 막대 5개(항목 번호 01·02·03 과 숫자 체계가 겹치지 않게)
// 4회차: 범위(~)는 한 덩어리(줄 끝에 ~ 가 매달리지 않게, 안 들어가면 글자를 줄인다) · 후 값 글자 크기 = 2~4장 공통(가장 빠듯한 장 기준) ·
// 전·후 줄 높이를 남는 높이에서 같은 비율로 나눔(메모 없는 장도 같은 규칙) · 전 값 하한 64px + 취소선 자리를 540 축소본 숫자 구분 검사로 고름 ·
// 종이 결 세기가 영역마다 다름 + 인쇄 단서(영역마다 다른 잉크 번짐·농도, 요소마다 다른 눌림, 포인트색 판 1px 어긋남) · 540 축소본 점 검사
// 6회차: 5장 날짜–한 줄 간격 = 1장 규칙, 문의 줄 많으면 한 줄 한 단계 작게·한 줄–문의 사이 ≥ 84px·문의 줄 높이 글자 × 2(last-gap 검사), 밝은 반전 바탕은 잉크 농도 얼룩 0.3배
// 5회차: 항목 이름 줄바꿈 금지(한 줄 공통 크기, 2~4장 같게) · 판 점·얼룩 정리를 판 전체로(결은 유지) · 취소선이 ~ 같은 기호의 잉크를 피하는 높이 ·
// 짧은 값(한 줄 폭이 칸의 절반 이하) = 공통 '짧은 값 크기'(SHORT_MIN 이상)로 키워 빈 서식처럼 보이지 않게
type Change = {name: string; before: string; after: string; note: string};
type Props = {
slide: number; brand: string; date: string; title: string;
changes: Change[];
last: {date: string; title: string; lines: string[]};
colors: {paper: string; ink: string; accent: string}; plate: string;
};
const defaults: Props = {
slide: 1, brand: '산책커피', date: '10.07', title: '달라진 것 세 가지',
changes: [
{name: '여는 시간', before: '오전 10시', after: '아침 8시', note: ''},
{name: '원두', before: '하우스 블렌드 하나', after: '디카페인 추가', note: ''},
{name: '쉬는 날', before: '일요일', after: '월요일', note: ''},
],
last: {date: '10.07', title: '이날부터 바뀝니다', lines: ['문의 02-000-0000']},
colors: {paper: '#EFE9DD', ink: '#2A2622', accent: '#C24A2C'}, plate: 'plates/plate.jpg',
};
// 배치(1080×1350) — 다섯 장이 같은 격자: 위 띠(브랜드·쪽 번호) → 위 괘선 → 내용 → 아래 괘선
const L = 68, R = 1017, BRAND_Y = 125, TOP_RULE = 187, BOTTOM_RULE = 1250;
const NUM = 'Source Serif 4', NUM_W = 900; // 날짜·번호: 굵은 디스플레이 세리프(가는 획도 두껍다 → 11 의 세리프가 붙거나 4 가 1 처럼 보이지 않는다)
const NO_MAX = 540; // 2~4장 큰 번호 최대(빈 높이를 흡수할 때)
const nameCap = (nos: number) => Math.min(150, (112 * nos) / 380); // 항목 이름 최대 크기 = 번호 크기에 비례
const DATE_MAX = 600, NO_SIZE = 380, TITLE_MAX = 150, TITLE_LH = 1.22;
const LAST_AIR = 84; // 5장 한 줄 잉크 아랫끝 ~ 첫 문의 줄 괘선 최소 간격(6회차, 5회차 48 → 아래가 빽빽했다)
const COVER_TITLE_MAX = 230, COVER_GAP = 72; // 1장 제목 최대 크기, 날짜 잉크 아랫끝 ~ 제목 잉크 윗끝 간격(px)
const AFTER_MAX = 260, AFTER_LH = 1.14;
const VAL_X = L + 96; // 전·후 값의 왼쪽(라벨 '전'·'후' 오른쪽)
const FIBER_OP = 0.7; // 섬유 overlay 투명도
const BEFORE_MIN = 64, STRIKE_MIN = 0.6;
const NAME_MIN = 64; // 항목 이름 한 줄 공통 크기 하한(px). 아래면 'name' 실패 → 이름을 줄여 달라고
const SHORT_W = 0.5, SHORT_MIN = 150; // 짧은 값: 공통 크기 S 에서 한 줄 폭 ≤ 칸 × 0.5. 짧은 값끼리 공통 크기 = 각자 한 줄로 들어가는 크기 중 가장 작은 것(최대 AFTER_MAX, 메모 있으면 200), 하한 max(SHORT_MIN, S × 1.3) — 하한을 못 넘으면 짧은 값 크기를 따로 두지 않는다 // 전 값 하한(px), 줄 그은 숫자 구분 비율 하한(아래 pickStrike) // 2~4장 후 값 최대 크기(메모가 없으면 남는 높이만큼 커진다), 두 줄일 때 줄 높이
const FONT_CSS = ['https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@36,900&family=Noto+Serif+KR:wght@500;700&display=block'];
const CROPS: Crop[] = [{s: 1, fx: 0.5, fy: 0.5}, {s: 1.1, fx: 0.15, fy: 0.2}, {s: 1.12, fx: 0.85, fy: 0.3}, {s: 1.1, fx: 0.3, fy: 0.85}, {s: 1.08, fx: 0.8, fy: 0.8}];
const press = (frame: number, s: number) => ({inkOn: ramp(frame, s, s + 3, Easing.out(Easing.quad)), dent: frame < s ? 0 : Math.max(0, 1 - (frame - s) / 8)});
// 숫자(날짜·번호) 조판: 잉크 상자 사이 간격 숫자 0.05em, 점 양옆 0.04em
const dateGlyphs = (t: string, D: number): GlyphIn[] => [...t].map((c) => ({c, family: NUM, weight: NUM_W, size: D, role: /\d/.test(c) ? 'digit' : 'mark'}));
const dateGap = (D: number) => (a: GlyphIn, b: GlyphIn) => (a.role === 'mark' || b.role === 'mark' ? 0.04 : 0.05) * D;
const fitDate = (t: string, maxW: number, maxD: number) => {
let D = maxD;
for (let k = 0; k < 3; k++) { const w = setInk(dateGlyphs(t, D), dateGap(D)).width; if (w <= maxW + 0.5) break; D = D * (maxW / w) * 0.995; }
const row = alignUnits(setInk(dateGlyphs(t, D), dateGap(D)).glyphs);
return {D, row, capT: Math.max(...row.map((g) => g.ink.t)), width: Math.max(...row.map((g) => g.x + g.ink.r))};
};
type DateLay = ReturnType<typeof fitDate>;
const lum = (h: string) => { const c = hexRgb(h).map((v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
const contrast = (a: string, b: string) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
// 두 줄로 나눈 안: 끊는 자리 = 빈칸(빈칸은 지움) 또는 ~·,· 뒤(기호는 윗줄 끝에 남김). 두 줄 폭이 가장 비슷한 자리(없으면 null)
// 범위 = ~(물결)·∼·– 가 든 낱말에서 양쪽으로 숫자가 든 낱말을 모두 붙이고, 그 바깥 숫자 없는 낱말 하나(오전·밤·저녁 같은 말)까지
// ("오전 8시 30분~저녁 8시" 전체, "매주 목요일 저녁 7시~9시" 의 "저녁 7시~9시"). 범위 안(빈칸·~ 뒤)에서는 끊지 않는다
// (3회차 "오전 9시~ / 밤 9시" — 물결이 줄 끝에 매달림). 범위 밖 빈칸·쉼표에서는 끊을 수 있고, 범위가 길면 글자를 줄인다
const RANGE = /[~∼〜–]/;
const rangeSpans = (t: string) => {
const words: [number, number][] = [];
t.replace(/[^ ,·]+/g, (m, i: number) => { words.push([i, i + m.length]); return m; });
const out: [number, number][] = [];
const glued = (x: number, y: number) => !/[,·]/.test(t.slice(x, y)); // 쉼표·가운뎃점을 넘지 않는다
const digit = (k: number) => /\d/.test(t.slice(words[k][0], words[k][1]));
words.forEach(([a, b], k) => {
if (!RANGE.test(t.slice(a, b))) return;
let l = k, r = k;
while (l > 0 && glued(words[l - 1][1], words[l][0]) && digit(l - 1)) l--;
if (l > 0 && glued(words[l - 1][1], words[l][0])) l--;
while (r < words.length - 1 && glued(words[r][1], words[r + 1][0]) && digit(r + 1)) r++;
out.push([words[l][0], words[r][1]]);
});
return out;
};
const half = (t: string, tw: (s: string) => number) => {
const cuts: [string, string][] = [];
const spans = rangeSpans(t), inRange = (i: number) => spans.some(([a, b]) => i > a && i < b);
[...t].forEach((c, i) => { if (i > 0 && i < t.length - 1 && !inRange(i)) { if (c === ' ') cuts.push([t.slice(0, i), t.slice(i + 1)]); else if (/[,·]/.test(c) && t[i + 1] !== ' ') cuts.push([t.slice(0, i + 1), t.slice(i + 1)]); } });
if (!cuts.length) return null;
// 윗줄이 한 글자 낱말로 끝나면("서울 전 / 지역 배달") 그 낱말이 뒤 낱말과 떨어져 어색하다 → 벌점 1.5배
const score = (c: [string, string]) => Math.max(tw(c[0]), tw(c[1])) * ((c[0].split(' ').pop() ?? '').length === 1 ? 1.5 : 1);
return cuts.reduce((a, b) => (score(b) < score(a) ? b : a));
};
// 글자 덩어리 크기 고르기: 한 줄 / 두 줄 중 폭·높이 안에서 더 크게 되는 쪽(두 줄은 1.15배 이상 커질 때만)
// fam = 굵기·서체, cap = 최대 크기, maxW·maxH = 들어갈 폭·높이(잉크 기준), lhK = 줄 높이 배수
const fitBlock = (t: string, fam: string, cap: number, maxW: number, maxH: number, lhK: number, allowTwo = true) => {
const g = document.createElement('canvas').getContext('2d')!;
const w = (s: string, sz: number) => { g.font = `${fam} ${sz}px "Noto Serif KR"`; return g.measureText(s).width + 4; };
const ink100 = inkBox(t, `${fam} 100px "Noto Serif KR"`, 100, 100 * lhK);
const sizeOf = (ls: string[]) => {
const byW = Math.min(...ls.map((l) => (maxW / w(l, 100)) * 100));
const byH = maxH / ((ls.length - 1) * lhK + ink100.h / 100);
return Math.min(cap, byW, byH);
};
let lines = [t], size = sizeOf(lines);
const two = allowTwo ? half(t, (s) => w(s, 100)) : null;
if (two && sizeOf(two) > size * 1.15) { lines = two; size = sizeOf(two); }
const ib = inkBox(lines[0], `${fam} ${size}px "Noto Serif KR"`, size, size * lhK);
const lastIb = inkBox(lines[lines.length - 1], `${fam} ${size}px "Noto Serif KR"`, size, size * lhK);
// 덩어리 잉크 높이 = 첫 줄 잉크 위 ~ 마지막 줄 잉크 아래
const inkH = (lines.length - 1) * size * lhK + lastIb.bottom - ib.top;
return {lines, size, lh: size * lhK, inkTop: ib.top, inkH};
};
// 고정 크기에서 줄 나누기: 한 줄에 들어가면 한 줄, 아니면 범위를 끊지 않는 두 줄(half)
const linesAt = (t: string, fam: string, size: number, maxW: number) => {
const g = document.createElement('canvas').getContext('2d')!; g.font = `${fam} ${size}px "Noto Serif KR"`;
const w = (s: string) => g.measureText(s).width + 4;
if (w(t) <= maxW + 0.5) return [t];
return half(t, w) ?? [t];
};
// 취소선 자리 고르기(4회차 → 5회차): 전 값의 숫자를 540 축소본 크기로 그려, 줄을 그은 숫자끼리의 차이가 줄 없는 숫자끼리의 차이에 비해 얼마나 남는지(비율) 잰다.
// 5회차: ① 기호(~·-·:·/ 등 숫자·한글이 아닌 글자)의 잉크 높이를 재서, 취소선(두께 + 여유 max(3px, 두께))이 기호 잉크와 겹치는 높이는 후보에서 뺀다
// (4회차 "10시~저녁" — 물결이 줄에 묻힘) ② 한글이 있으면 숫자 높이 30~55% 밖은 벌점(4회차 '2대' — 줄이 '대' 윗부분에 걸림)
// 후보 = 숫자 높이 20~75%(2.5% 간격). 점수 = 540 구분 비율(숫자 없으면 1) − 벌점. 비율 < 0.6 이면 'strike-540', 기호를 피할 자리가 없으면 'strike-symbol' 실패
const pickStrike = (text: string, size: number) => {
const digits = [...new Set([...text].filter((c) => /\d/.test(c)))].join('');
const syms = [...new Set([...text].filter((c) => !/[\d\s가-힣A-Za-z]/.test(c)))];
const hangul = /[가-힣]/.test(text);
const th = Math.max(3, size * 0.042);
const x = document.createElement('canvas').getContext('2d')!; x.font = `500 ${size}px "Noto Serif KR"`;
const capH = x.measureText('0123456789').actualBoundingBoxAscent;
const clear = Math.max(3, th);
// 기호 잉크 띠(기준선 위 px): [아래, 위]
const symBands = syms.map((c) => { const m = x.measureText(c); return {c, lo: -m.actualBoundingBoxDescent, hi: m.actualBoundingBoxAscent}; }).filter((b) => b.hi - b.lo > 0.5);
const hitsSym = (pos: number) => { const y = pos * capH; return symBands.some((b) => y + th / 2 + clear > b.lo && y - th / 2 - clear < b.hi); };
const s2 = size / 2, B = Math.ceil(s2 * 1.6), base = Math.round(B * 0.8);
const c = document.createElement('canvas'); c.width = B; c.height = B;
const g = c.getContext('2d', {willReadFrequently: true})!;
const raster = (d: string, pos: number | null) => {
g.clearRect(0, 0, B, B); g.font = `500 ${s2}px "Noto Serif KR"`; g.fillStyle = '#000'; g.fillText(d, Math.round(s2 * 0.2), base);
if (pos !== null) g.fillRect(0, base - (pos * capH) / 2 - th / 4, B, th / 2);
const a = g.getImageData(0, 0, B, B).data, o = new Float32Array(B * B);
for (let i = 0; i < B * B; i++) o[i] = a[i * 4 + 3] / 255;
return o;
};
const dist = (A: Float32Array, Bv: Float32Array) => { let num = 0, den = 0; for (let i = 0; i < A.length; i++) { num += Math.abs(A[i] - Bv[i]); den += Math.max(A[i], Bv[i]); } return num / Math.max(1e-9, den); };
const all = '0123456789', plain = digits ? new Map([...all].map((d) => [d, raster(d, null)] as const)) : null;
const ratioAt = (pos: number) => {
if (!digits) return {mn: 1, wp: ''};
const st = new Map([...all].map((d) => [d, raster(d, pos)] as const));
let mn = 9, wp = '';
for (const d of digits) for (const e of all) { if (e === d) continue; const r = dist(st.get(d)!, st.get(e)!) / dist(plain!.get(d)!, plain!.get(e)!); if (r < mn) { mn = r; wp = d + e; } }
return {mn, wp};
};
let best = {pos: 0.4, minRatio: -1, worst: '', score: -9, symbolClear: false};
for (let pos = 0.2; pos <= 0.751; pos += 0.025) {
const sym = hitsSym(pos);
const {mn, wp} = ratioAt(pos);
const pen = (sym ? 5 : 0) + (hangul ? Math.max(0, 0.3 - pos, pos - 0.55) * 4 : 0);
const score = Math.min(mn, 1) - pen - 0.15 * Math.abs(pos - 0.42); // 같으면 숫자 높이 42%(한글 가운데)에 가까운 쪽
if (score > best.score) best = {pos: +pos.toFixed(3), minRatio: +mn.toFixed(3), worst: wp, score, symbolClear: !sym};
}
return {pos: best.pos, minRatio: digits ? best.minRatio : (null as number | null), worst: best.worst, th, capH, digits, symbols: symBands.map((b) => b.c).join(''), symbolClear: best.symbolClear};
};
// 한 줄 크기(5회차): 폭 maxW 안에 한 줄로 들어가는 가장 큰 크기(최대 cap)
const oneLine = (t: string, fam: string, cap: number, maxW: number) => { const g = document.createElement('canvas').getContext('2d')!; g.font = `${fam} 100px "Noto Serif KR"`; return Math.min(cap, (maxW / (g.measureText(t).width + 4)) * 100); };
// 쪽 표시: 숫자 대신 막대 5개(지금 장만 진하게) — 2~4장 큰 번호 01·02·03 과 숫자 체계가 겹치지 않는다
const TICK_W = 30, TICK_GAP = 9, TICKS_W = 5 * TICK_W + 4 * TICK_GAP;
type Block = {lines: string[]; size: number; lh: number; top: number};
type Lay = {
ready: boolean; big: DateLay | null; bigTop: number;
title: Block; before: {text: string; size: number; top: number; h: number; strike: ReturnType<typeof pickStrike>; base?: number} | null;
after: (Block & {rowTop: number; rowH: number}) | null;
rows: {text: string; size: number; top: number; h: number}[];
note: {size: number; top: number}; head: number; plate: string | null; fiber: string | null;
};
const Update: React.FC<Props> = (p) => {
const n = p.slide;
const ch = n >= 2 && n <= 4 ? p.changes[n - 2] : null;
const [handle] = useState(() => delayRender('fonts+fit'));
const [look, setLook] = useState<ReturnType<typeof plateLook> | null>(null);
const root = React.useRef<HTMLDivElement>(null);
// 5장은 색면을 뒤집는다: 바탕 = 날짜색(종이색과 대비 3 이상일 때) 아니면 글자색, 글자 = 종이색
const flip = n === 5;
const fill = flip ? (contrast(p.colors.accent, p.colors.paper) >= 3 ? p.colors.accent : p.colors.ink) : p.colors.paper;
const ink = flip ? p.colors.paper : p.colors.ink;
const accent = flip ? p.colors.paper : p.colors.accent;
// 밝은 반전 바탕(6회차, r5 검수 — 노랑 바탕 "06.15" 획 안 명암이 얼룩덜룩해 필터 티): 잉크 농도 얼룩·종이 얼룩을 낮춘다(어두운 바탕은 그대로)
const brightFlip = flip && lum(fill) > 0.3;
const head = n === 1 || n === 5 ? p.brand : p.title; // 위 띠 왼쪽: 1·5장 브랜드, 2~4장 1장 제목(브랜드 금지)
const bigText = n === 1 ? p.date : n === 5 ? p.last.date : String(n - 1).padStart(2, '0');
const [fix, setFix] = useState<number[]>([]);
const [lay, setLay] = useState<Lay>({ready: false, big: null, bigTop: 0, title: {lines: [], size: 100, lh: 122, top: 0}, before: null, after: null, rows: [], note: {size: 44, top: 0}, head: 1, plate: null, fiber: null});
const crop = CROPS[n - 1] ?? CROPS[0];
useEffect(() => {
(async () => {
checkSlide(p);
if (p.changes.length !== 3) throw new Error('changes 는 3개(2~4장)');
if (p.last.lines.length < 1 || p.last.lines.length > 3) throw new Error('5장 줄은 1~3개');
await loadFonts(FONT_CSS, [
[`${NUM_W} 100px "${NUM}"`, '0123456789./ ' + p.date + p.last.date],
['700 100px "Noto Serif KR"', p.brand + p.title + p.last.title + p.changes.map((c) => c.name + c.after).join('') + '전후'],
['500 100px "Noto Serif KR"', p.changes.map((c) => c.before + c.note).join('') + p.last.lines.join('')],
]);
const pa = await analysePlate(staticFile(p.plate));
const lk = plateLook(pa.mean, pa.lum, fill);
const gain = lk.mode === 'tint' ? 1.6 * (0.5 / Math.max(0.03, pa.lum)) : 1;
const g = document.createElement('canvas').getContext('2d')!;
const mw = (s: string, font: string) => { g.font = font; return g.measureText(s).width; };
const maxW = R - L;
const over: string[] = [];
const warn: string[] = [];
const headScale = Math.min(1, (R - L - TICKS_W - 40) / mw(head, `700 ${n === 1 || n === 5 ? 56 : 44}px "Noto Serif KR"`));
let big: DateLay, bigTop: number, title: Block, before: Lay['before'] = null, after: Lay['after'] = null, rows: Lay['rows'] = [], note = {size: 44, top: 0};
const gaps: Record<string, number | number[]> = {};
if (n === 1) {
// 1장: 날짜(폭에 꽉 차게) → 72px → 제목(남는 높이·폭 안에서 가장 크게, 최대 230px, 두 줄까지). 덩어리를 위·아래 괘선 사이 가운데에
big = fitDate(bigText, maxW, DATE_MAX);
const top0 = TOP_RULE + 56, bot0 = BOTTOM_RULE - 52, RH = bot0 - top0;
const tb = fitBlock(p.title, '700', COVER_TITLE_MAX, maxW, RH - big.capT - COVER_GAP, TITLE_LH);
const left = Math.max(0, RH - big.capT - COVER_GAP - tb.inkH);
const gap = COVER_GAP + Math.min(40, left * 0.25); // 남는 높이는 조금만 간격에, 나머지는 위·아래 같게
const blockTop = top0 + (RH - (big.capT + gap + tb.inkH)) / 2;
bigTop = blockTop;
title = {lines: tb.lines, size: tb.size, lh: tb.lh, top: blockTop + big.capT + gap - tb.inkTop};
gaps.dateTitle = Math.round(gap);
if (tb.lines.length > 2 || tb.size < TITLE_MAX * 0.55) over.push('title');
} else if (n === 5) {
// 5장: 적용일 → 한 줄 → 문의 줄(아래 괘선 쪽부터). 6회차(r5 검수): 날짜–한 줄 간격을 1장과 같은 규칙(COVER_GAP + 남는 높이 25%, 최대 +40)으로,
// 한 줄 최대 크기는 문의 줄 수만큼 한 단계씩 줄이고(1줄 190 · 2줄 160 · 3줄 140), 한 줄–문의 사이 ≥ LAST_AIR, 문의 줄 높이 = 글자 × 2(빽빽하지 않게)
big = fitDate(bigText, maxW, DATE_MAX);
bigTop = TOP_RULE + 64;
const dateBottom = bigTop + big.capT;
const k = p.last.lines.length, cap = [72, 58, 50][k - 1], pitch = cap * 2;
p.last.lines.forEach((t, i) => {
const sz = Math.min(cap, (maxW / mw(t, `500 ${cap}px "Noto Serif KR"`)) * cap);
if (sz < cap * 0.72) over.push('row' + (i + 1));
rows.push({text: t, size: sz, top: BOTTOM_RULE - (k - i) * pitch, h: pitch});
});
const linesTop = BOTTOM_RULE - k * pitch;
const avail = linesTop - LAST_AIR - (dateBottom + COVER_GAP);
const tb = fitBlock(p.last.title, '700', [190, 160, 140][k - 1], maxW, avail, TITLE_LH);
const left = Math.max(0, avail - tb.inkH);
const gap = COVER_GAP + Math.min(40, left * 0.25); // 1장과 같은 날짜–제목 간격 규칙
const inkTop = dateBottom + gap + (left - (gap - COVER_GAP)) * 0.3; // 나머지는 3:7 로 날짜 쪽·문의 쪽에(문의 위가 더 넉넉하게)
title = {lines: tb.lines, size: tb.size, lh: tb.lh, top: inkTop - tb.inkTop};
gaps.dateTitle = Math.round(inkTop - dateBottom); gaps.titleLines = Math.round(linesTop - (inkTop + tb.inkH)); gaps.rowPitch = Math.round(pitch);
if (tb.lines.length > 2 || tb.size < TITLE_MAX * 0.55) over.push('title');
if (avail < 120) over.push('title-lines');
if ((gaps.dateTitle as number) < COVER_GAP - 0.5 || (gaps.titleLines as number) < LAST_AIR - 0.5) over.push('last-gap');
} else {
// 2~4장: 큰 번호(왼쪽) + 항목 이름(오른쪽 칸, 번호 아랫선에 맞춤) → 전(줄 그음) → 후 → 메모
// 세 장의 배치를 모두 계산해 후 값 글자 크기를 하나로 맞춘다(가장 빠듯한 장 기준 — 3회차: 장마다 크기가 들쭉날쭉)
const planAt = (nos: number) => p.changes.map((c, j) => {
const bg = fitDate(String(j + 1).padStart(2, '0'), maxW * 0.5, nos);
const base = TOP_RULE + 56 + bg.capT;
const r0 = base + 70, noteH = c.note ? 120 : 0;
// 남는 높이(전 줄 위 ~ 메모 위)를 전 38% : 후 62% 로 나눈다 — 메모가 없는 장도 같은 규칙(빈 곳은 두 줄이 나눠 흡수)
const free = BOTTOM_RULE - r0 - noteH, BH = Math.round(free * 0.38), rowTop = r0 + BH, rowH = BOTTOM_RULE - rowTop - noteH;
const fb = fitBlock(c.after, '700', c.note ? 200 : AFTER_MAX, R - VAL_X, rowH - 2 * 46, AFTER_LH);
return {nos, bg, base, r0, BH, rowTop, rowH, noteH, fit: fb.size};
});
// 후 값이 범위·긴 값 때문에 작아지면 아래가 빈다 → 큰 번호(과 이름)를 키워 빈 높이를 흡수한다(세 장 같은 크기).
// 380px 에서 20px 씩 키우며, 후 공통 크기가 줄지 않고 세 장 모두 후 줄 위아래 여백 ≥ 56px·전 줄 여유·이름 두 줄 이내일 때까지(최대 NO_MAX)
const evalAt = (nos: number) => {
const pl = planAt(nos), S0 = Math.min(...pl.map((q) => q.fit));
const ok = pl.every((q, j) => {
const c = p.changes[j], at = linesAt(c.after, '700', S0, R - VAL_X);
const i0 = inkBox(at[0], `700 ${S0}px "Noto Serif KR"`, S0, S0 * AFTER_LH), iL = inkBox(at[at.length - 1], `700 ${S0}px "Noto Serif KR"`, S0, S0 * AFTER_LH);
const pad = (q.rowH - ((at.length - 1) * S0 * AFTER_LH + iL.bottom - i0.top)) / 2;
const ns = oneLine(c.name, '700', nameCap(nos), R - (L + q.bg.width + 56)); // 5회차: 이름은 한 줄
return pad >= 56 && q.BH >= Math.min(80, S0 * 0.7) * 1.3 + 60 && ns >= Math.max(NAME_MIN, nameCap(nos) * 0.6);
});
return {pl, S: S0, ok};
};
const e0 = evalAt(NO_SIZE);
let pick = e0;
for (let nos = NO_SIZE + 20; nos <= NO_MAX; nos += 20) { const e = evalAt(nos); if (e.ok && e.S >= e0.S - 0.5) pick = e; else break; }
const plan = pick.pl, S = pick.S;
gaps.afterSizeAll = Math.round(S); gaps.numberSize = plan[0].nos;
const me = plan[n - 2];
big = me.bg;
bigTop = TOP_RULE + 56;
const base = me.base;
const colX = L + big.width + 56, colW = R - colX;
// 항목 이름: 줄바꿈 금지(5회차 — 4회차 "여는 / 시간" 만 두 줄로 꺾여 장 사이 위계가 흔들렸다). 세 장 모두 한 줄로 들어가는 크기 중 가장 작은 것 = 공통 크기
const nc0 = nameCap(me.nos);
const NS = Math.min(...plan.map((q, j) => oneLine(p.changes[j].name, '700', nc0, R - (L + q.bg.width + 56))));
const nb = {lines: [ch!.name], size: NS, lh: NS * TITLE_LH};
if (NS < NAME_MIN) over.push('name');
if (mw(ch!.name, `700 ${NS}px "Noto Serif KR"`) + 4 > colW + 0.5) over.push('name-wrap');
gaps.nameSizeAll = Math.round(NS);
// 이름 마지막 줄 잉크 아랫끝 = 번호 기준선
const lastIb = inkBox(nb.lines[nb.lines.length - 1], `700 ${nb.size}px "Noto Serif KR"`, nb.size, nb.lh);
title = {lines: nb.lines, size: nb.size, lh: nb.lh, top: base - (nb.lines.length - 1) * nb.lh - lastIb.bottom};
// 전 값: 후보다 작게(후 × 0.7, 최대 80px), 하한 64px(540 에서 32px — 줄 그은 숫자 8·9 구분). 하한 아래면 실패 → 줄여 달라고
const bFit = ((R - VAL_X) / mw(ch!.before, '500 80px "Noto Serif KR"')) * 80;
const bSize = Math.min(80, bFit, Math.max(BEFORE_MIN, S * 0.7));
if (bSize < BEFORE_MIN) over.push('row1');
const sc = pickStrike(ch!.before, bSize);
before = {text: ch!.before, size: bSize, top: me.r0, h: me.BH, strike: sc};
if (sc.digits && sc.minRatio! < STRIKE_MIN) over.push('strike-540');
if (!sc.symbolClear) over.push('strike-symbol');
// 후 = 공통 크기 S. 단 짧은 값(S 에서 한 줄 폭 ≤ 칸 × SHORT_W, 예: "없음"·"8대")은 짧은 값 공통 크기 SS 로 키운다(5회차 — S 로 두면 아래 절반이 빈 서식처럼 보였다)
const VW = R - VAL_X;
const isShort = (j: number) => mw(p.changes[j].after, `700 ${S}px "Noto Serif KR"`) + 4 <= VW * SHORT_W;
const shortIdx = [0, 1, 2].filter(isShort);
const ssRaw = shortIdx.length ? Math.min(...shortIdx.map((j) => { const q = plan[j]; const c = p.changes[j]; const ib = inkBox(c.after, '700 100px "Noto Serif KR"', 100, 100 * AFTER_LH); return Math.min(c.note ? 200 : AFTER_MAX, oneLine(c.after, '700', 999, VW), (q.rowH - 2 * 46) / (ib.h / 100)); })) : 0;
const SS = ssRaw >= Math.max(SHORT_MIN, S * 1.3) ? ssRaw : S;
const mine = shortIdx.includes(n - 2) ? SS : S;
gaps.afterShortSize = Math.round(SS); gaps.afterShortSlides = shortIdx.map((j) => j + 2);
const at = mine === S ? linesAt(ch!.after, '700', S, R - VAL_X) : [ch!.after];
const ib0 = inkBox(at[0], `700 ${mine}px "Noto Serif KR"`, mine, mine * AFTER_LH), ibL = inkBox(at[at.length - 1], `700 ${mine}px "Noto Serif KR"`, mine, mine * AFTER_LH);
const inkH = (at.length - 1) * mine * AFTER_LH + ibL.bottom - ib0.top;
if (S < 72) over.push('row2');
if (me.rowH < 200) over.push('rows-height');
const inkTop = me.rowTop + (me.rowH - inkH) / 2;
after = {lines: at, size: mine, lh: mine * AFTER_LH, top: inkTop - ib0.top, rowTop: me.rowTop, rowH: me.rowH};
if (shortIdx.includes(n - 2) && SS > S && (me.rowH - inkH) / 2 < 30) over.push('row2-short');
gaps.afterPad = Math.round((me.rowH - inkH) / 2);
if (ch!.note) {
const ns = Math.min(46, ((R - VAL_X) / mw(ch!.note, '500 46px "Noto Serif KR"')) * 46);
if (ns < 46 * 0.8) over.push('note');
note = {size: ns, top: BOTTOM_RULE - me.noteH / 2 - 32};
}
// 범위 줄바꿈 검사: 어느 줄도 ~ 로 끝나거나 시작하지 않는다
if ([...at, ...title.lines].some((l) => /[~∼〜–]\s*$/.test(l) || /^\s*[~∼〜–]/.test(l))) over.push('range-break');
}
setLay((o) => ({...o, ready: true, big, bigTop, title, before, after, rows, note, head: headScale}));
await settle();
// 취소선 기준선: 화면에서 전 값의 기준선 표식을 재서 그 위 pos × 숫자 높이에 긋는다
if (before) {
const el = document.querySelector<HTMLElement>('[data-before]'), m = el?.querySelector<HTMLElement>('[data-bl]');
const bb = m ? m.offsetTop : before.h * 0.7;
setLay((o) => ({...o, before: o.before ? {...o.before, base: bb} : null}));
await settle();
}
// 빈 띠 검사: 내용 사이 가장 큰 빈 높이(px). 250px 넘으면 warn(레이아웃이 흡수하지 못한 빈칸)
const ys = textRects(root.current).map((r) => [r.y, r.y + r.h] as [number, number]).sort((a, b) => a[0] - b[0]);
let cov = TOP_RULE, blank = 0;
for (const [a, b] of ys) { if (a > cov) blank = Math.max(blank, a - cov); cov = Math.max(cov, b); }
blank = Math.max(blank, BOTTOM_RULE - cov);
if (blank > 250) warn.push(`빈 띠 ${Math.round(blank)}px`);
// 판 정리(5회차): 판 전체에서 점·얼룩을 지운다(4회차까지는 글자 줄만 → 위 여백·괘선 근처 갈색 티끌이 남아 검수 ③⑦ 감점).
// 기준은 같다(흐린 판과의 차이가 결 세기 σ 의 3.5배 이상인 6~8000px 덩어리) — 고른 종이 결은 기준을 넘지 않아 그대로 남는다(texture.cv 검사)
const bands = [{x: 0, y: 0, w: W, h: H}];
const cp = await cleanPlateFor540(staticFile(p.plate), crop, bands, gain, lk, fill, FIBER_OP, 3 + n * 5);
setLook(lk);
setLay((o) => ({...o, plate: cp.plate, fiber: cp.fiber}));
await settle();
if (cp.specks.after > 0) over.push('plate-specks');
if (cp.specks540.count > 0) over.push('specks-540');
if (cp.texture.cv < 0.2) over.push('texture-uniform');
const nc = numberCheck(ch ? 'number' : 'date', big.row, big.D);
over.push(...nc.over);
const dm = domMatch(); if (dm > 1) over.push('number-font');
const bl = await fixBaselines(setFix); if (bl.after > 1) over.push('number-baseline');
if (headScale < 0.75) over.push('head');
if (!ch && big.D < 220) over.push('big');
if (n === 1 && !/^[\d.\-/ ]{1,5}$/.test(p.date)) over.push('date-format');
if (n === 5 && !/^[\d.\-/ ]{1,5}$/.test(p.last.date)) over.push('date-format');
if (n === 1 && ((gaps.dateTitle as number) < 60 || (gaps.dateTitle as number) > 140)) over.push('title-date');
const leaks = ch ? brandLeaks(p.brand, [p.title, ch.name, ch.before, ch.after, ch.note]) : [];
if (leaks.length) over.push('brand-2to4');
const hint = over.length ? '넘침·규칙 위반 = ' + over.join(', ') + (leaks.length ? ' (2~4장에는 브랜드명을 쓰지 않는다: ' + leaks.join(' | ') + ')' : '') + (over.some((o) => /-(gap|thin|unit-baseline)$|number-font|number-baseline|plate-specks|specks-540|texture-uniform/.test(o)) ? ' / 숫자 조판·판 점·질감 검사 실패는 코드 문제다 → 고치지 말고 check.json 을 그대로 보고' : ' → 사용자에게 줄이거나 고친 안을 물어본 뒤 props.json 을 고쳐 다시') : '';
report({slide: n, ok: over.length === 0, overflow: over, ...(warn.length ? {warn} : {}), plateMode: lk.mode, fill, printDensity: brightFlip ? 0.3 : 1, bigSize: Math.round(big.D), numberCheck: {...nc.report, domAdvanceErrPx: dm, domBaselineFixPx: bl.before, domBaselineErrPx: bl.after}, title: {lines: title.lines, size: Math.round(title.size)}, ...(after ? {after: {lines: after.lines, size: Math.round(after.size)}} : {}), rows: rows.map((r) => ({size: Math.round(r.size)})), ...(before ? {before: {size: Math.round(before.size), strike: {posOfDigitHeight: before.strike.pos, thicknessPx: +before.strike.th.toFixed(1), digits: before.strike.digits, minRatio540: before.strike.minRatio, worstPair: before.strike.worst, symbols: before.strike.symbols, symbolClear: before.strike.symbolClear}}} : {}), gaps, maxBlankPx: Math.round(blank), specks: cp.specks, specks540: cp.specks540, texture: cp.texture, hint});
await settle();
continueRender(handle);
})().catch((e) => cancelRender(e));
}, []);
const frame = useCurrentFrame();
const hl = flip ? 0.16 : 0.35;
// 눌림: 요소(k)마다 0.7~1.3배(4회차) — 한 장이 고르게 눌려 보이지 않게. reg = 포인트색 판 1px 어긋남(포인트색 요소에만)
const deboss = (dent: number, k = 0, reg = false): React.CSSProperties => {
const q = pressK(n, k);
return {filter: `url(#print) drop-shadow(0 ${(-1.2 * q - 2.5 * dent).toFixed(2)}px ${0.5 + dent}px rgba(0,0,0,${(0.22 * q + 0.35 * dent).toFixed(3)})) drop-shadow(0 ${(1.2 * q + 1.5 * dent).toFixed(2)}px 0.5px rgba(255,255,255,${(hl * q + 0.2 * dent).toFixed(3)}))${reg ? ' ' + misreg(p.colors.ink, n) : ''}`};
};
const rule = (y: number, s: number, i: number) => (
<div key={'r' + i} style={{position: 'absolute', left: L, top: y - 1.5, width: R - L, height: 3, background: ink, opacity: 0.85, transformOrigin: '0 50%', transform: `scaleX(${ramp(frame, s, s + 14, Easing.bezier(0.3, 0, 0.2, 1))})`, filter: 'url(#print)'}} />
);
const roll = (s: number): React.CSSProperties => {
const t = ramp(frame, s, s + 12, Easing.bezier(0.4, 0, 0.2, 1));
const edge = -80 + t * (R - L + 160);
const m = `linear-gradient(90deg, #000 ${edge - 80}px, transparent ${edge}px)`;
return {WebkitMaskImage: m, maskImage: m};
};
const headP = press(frame, 2), bigP = press(frame, 5), titleP = press(frame, 11);
const big = lay.big;
const ruleYs = ch && lay.before && lay.after ? [lay.before.top, lay.after.rowTop, ...(ch.note ? [lay.after.rowTop + lay.after.rowH] : [])] : n === 5 ? lay.rows.map((r) => r.top) : [];
const serif = (w: number, size: number, lh: number, extra: React.CSSProperties = {}): React.CSSProperties => ({fontFamily: 'Noto Serif KR', fontWeight: w, fontSize: size, lineHeight: `${lh}px`, whiteSpace: 'pre', ...extra});
return (
<AbsoluteFill ref={root} style={{backgroundColor: fill, overflow: 'hidden', opacity: lay.ready ? 1 : 0}}>
{look && lay.plate && <Img src={lay.plate} style={look.style} />}
{/* 인쇄 단서(common PrintDefs): 가장자리 거칠기 · 영역마다 다른 잉크 번짐 · 잉크 농도 얼룩 */}
<PrintDefs seed={21 + n} spread={0.8} density={brightFlip ? 0.3 : 1} />
{rule(TOP_RULE, 0, 0)}
{ruleYs.map((y, i) => rule(y, 14 + i * 4, i + 1))}
{rule(BOTTOM_RULE, 18, 9)}
{/* 위 띠: 왼쪽 브랜드(1·5장) / 1장 제목(2~4장), 오른쪽 쪽 표시(막대 5개) */}
<div data-t style={{position: 'absolute', left: L, top: BRAND_Y - 42, lineHeight: '84px', width: 'max-content', fontFamily: 'Noto Serif KR', fontWeight: 700, fontSize: (n === 1 || n === 5 ? 56 : 44) * lay.head, color: ink, opacity: (n === 1 || n === 5 ? 1 : 0.78) * headP.inkOn, whiteSpace: 'pre', ...deboss(headP.dent, 1)}}>{head}</div>
<div data-t style={{position: 'absolute', left: R - TICKS_W, top: BRAND_Y - 3, width: TICKS_W, height: 6, opacity: headP.inkOn}}>
{Array.from({length: N}, (_, i) => <div key={i} style={{position: 'absolute', left: i * (TICK_W + TICK_GAP), top: 0, width: TICK_W, height: 6, background: ink, opacity: i === n - 1 ? 0.9 : 0.22, filter: 'url(#print)'}} />)}
</div>
{/* 큰 숫자 자리: 1·5장 = 날짜(폭에 꽉 차게), 2~4장 = 바뀐 항목 번호 */}
{big && (
<div style={{position: 'absolute', left: L, top: 0, width: big.width, height: H, color: accent, opacity: bigP.inkOn, transformOrigin: `0 ${lay.bigTop + big.capT}px`, transform: `scale(${1 + 0.012 * bigP.dent})`, ...deboss(bigP.dent, 2, !flip)}}>
{big.row.map((g, i) => <span key={i} data-t data-glyph={glyphData(g, lay.bigTop + big.capT)} style={glyphStyle(g, 0, lay.bigTop + big.capT, fix[i] ?? 0)}>{g.c}<BL /></span>)}
</div>
)}
{/* 제목: 1장 = 날짜 아래 큰 제목, 5장 = 날짜 아래 한 줄, 2~4장 = 번호 오른쪽 항목 이름 */}
<div style={{position: 'absolute', left: ch && big ? L + big.width + 56 : L, top: lay.title.top, opacity: titleP.inkOn, ...deboss(titleP.dent, 3)}}>
{lay.title.lines.map((t, i) => <div key={i} data-t style={serif(700, lay.title.size, lay.title.lh, {color: ink, letterSpacing: '-0.015em', width: 'max-content'})}>{t}</div>)}
</div>
{/* 2~4장: 전(줄 그음) */}
{lay.before && (() => {
const b = lay.before, s0 = 18;
const strike = ramp(frame, s0 + 10, s0 + 22, Easing.bezier(0.3, 0, 0.2, 1));
return (
<div style={{position: 'absolute', left: L, top: b.top, height: b.h, width: R - L, ...roll(s0)}}>
<div data-t style={serif(700, 46, b.h, {position: 'absolute', left: 0, top: 0, color: ink, opacity: 0.6, ...deboss(0, 4)})}>전</div>
<div data-t data-before style={serif(500, b.size, b.h, {position: 'absolute', left: VAL_X - L, top: 0, width: 'max-content', color: ink, opacity: 0.7, ...deboss(0, 5)})}>
{b.text}<BL />
<span style={{position: 'absolute', left: -6, right: -6, top: (b.base ?? b.h * 0.7) - b.strike.pos * b.strike.capH - b.strike.th / 2, height: b.strike.th, background: p.colors.accent, transformOrigin: '0 50%', transform: `scaleX(${strike})`, filter: 'url(#print)'}} />
</div>
</div>
);
})()}
{/* 2~4장: 후(남는 높이만큼 크게, 1~2줄) — '후' 표시는 첫 줄 잉크 가운데에 */}
{lay.after && (() => {
const a = lay.after, s0 = 24;
const firstMid = a.top + inkBox(a.lines[0], `700 ${a.size}px "Noto Serif KR"`, a.size, a.lh).top + (inkBox(a.lines[0], `700 ${a.size}px "Noto Serif KR"`, a.size, a.lh).h / 2);
return (
<div style={{position: 'absolute', left: L, top: 0, width: R - L, height: H, ...roll(s0)}}>
<div data-t style={serif(700, 46, 64, {position: 'absolute', left: 0, top: firstMid - 32, color: accent, ...deboss(0, 6, !flip)})}>후</div>
<div style={{position: 'absolute', left: 96, top: a.top}}>
{a.lines.map((t, i) => <div key={i} data-t style={serif(700, a.size, a.lh, {color: ink, letterSpacing: '-0.015em', width: 'max-content', ...deboss(0, 7 + i)})}>{t}</div>)}
</div>
</div>
);
})()}
{/* 5장: 문의 줄 */}
{lay.rows.map((r, i) => (
<div key={i} style={{position: 'absolute', left: L, top: r.top, height: r.h, width: R - L, ...roll(18 + i * 6)}}>
<div data-t style={serif(500, r.size, r.h, {position: 'absolute', left: 0, top: 0, width: 'max-content', color: ink, ...deboss(0, 10 + i)})}>{r.text}</div>
</div>
))}
{ch && ch.note && (
<div data-t style={serif(500, lay.note.size, 64, {position: 'absolute', left: L + 96, top: lay.note.top, width: 'max-content', color: ink, opacity: 0.8, ...roll(30), ...deboss(0, 14)})}>{ch.note}</div>
)}
{look && lay.fiber && <Img src={lay.fiber} style={{position: 'absolute', width: W, height: H, mixBlendMode: 'overlay', opacity: FIBER_OP}} />}
{/* 종이 얼룩(4회차): 아주 낮은 주파수 밝기 차이 — 장마다 자리가 다르다 */}
<PaperMottle seed={31 + n} opacity={brightFlip ? 0.16 : 0.3} />
<Sweep a={0.5} />
<Grain opacity={0.05} />
</AbsoluteFill>
);
};
registerRoot(() => <Composition id="Main" component={Update} 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: []`, 모든 장 `numberCheck` 의 `minGapEm` ≥ 0.025·`thin: []`·`domAdvanceErrPx` ≤ 1·`domBaselineErrPx` ≤ 1, 5장 `fill` 이 날짜색(또는 글자색), 밝은 반전 바탕이면 `printDensity` 0.3, 5장 `gaps.dateTitle` ≥ 72·`gaps.titleLines` ≥ 84. `overflow` 뜻: `head`(위 띠 글자), `big`(날짜가 너무 김), `date-gap`·`number-gap`(숫자 잉크 간격 부족)·`date-thin`·`number-thin`(가는 획)·`number-font`(화면 서체 ≠ 검사 서체)·`number-baseline`(화면 기준선 어긋남 — 이 넷은 코드 문제이니 고치지 말고 보고), `title`·`title-date`(1장 날짜–제목 간격이 60~140px 밖)·`title-lines`(제목이 길어 날짜·문의 줄과 닿음)·`last-gap`(5장 날짜–한 줄 72px 미만이거나 한 줄–문의 84px 미만 — 코드 문제), `plate-specks`·`specks-540`(판에 지우지 못한 점이 남음)·`texture-uniform`(종이 결이 균일)·`strike-540`(줄 그은 전 값의 숫자 구분이 540 에서 부족)·`strike-symbol`(취소선이 기호를 피할 자리가 없음) — 이 다섯은 코드 문제이니 고치지 말고 보고, `range-break`(줄이 ~ 로 끝나거나 시작 — 코드 문제), `name`(항목 이름이 길어 한 줄 공통 크기가 64px 아래 — 이름을 줄여 달라고)·`name-wrap`(코드 문제), `row2-short`(짧은 값 크기가 줄 높이에 빠듯 — 코드 문제), `row1`(전 값이 길어 64px 아래로 줄어야 함), `row2`(후 값 공통 크기가 72px 아래 — 가장 긴 후 값을 줄여 달라고), `row3`(그 줄이 너무 길어 많이 줄여야 함), `rows-height`, `note`, `date-format`(날짜가 숫자·점 5자 밖), `brand-2to4`(2~4장에 브랜드명). `title`·`after`·`rows` 의 size 는 코드가 정한 글자 크기(px). `specks` = 판 전체에서 찾은 점 수(`before`)와 지운 뒤 남은 점(`after`, 0 이어야 함), `specks540.count` = 0, `texture.cv` ≥ 0.2, 2~4장 `after.size` = `gaps.afterSizeAll`(보통 값) 또는 `gaps.afterShortSize`(짧은 값 장 `gaps.afterShortSlides`), `gaps.nameSizeAll`(이름 한 줄 공통 크기), `before.strike.symbolClear` = true, `before.strike.minRatio540` ≥ 0.6(숫자가 있을 때), `maxBlankPx` = 위·아래 괘선 사이 내용 사이 가장 큰 빈 높이(250px 넘으면 `warn` — 보고에 쓴다).
2. `out/s1.png` ~ `out/s5.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구). 확인: 브랜드·날짜·제목·이름·전·후·메모·문의 줄이 입력과 글자 하나까지 같은지(순서·띄어쓰기 포함), 1장이 날짜·제목만이고 둘 사이가 벌어져 보이지 않는지, 2~4장 항목 이름이 모두 한 줄·같은 크기인지, 후 값 글자 크기가 같은 규칙인지(짧은 값은 크게), 취소선이 ~ 같은 기호를 가리지 않는지, 판 여백(위·아래·모서리)에도 티끌·얼룩이 없는지, ~ 가 줄 끝에 매달리지 않았는지, 메모 없는 장·문의 한 줄인 5장의 아래·가운데가 비어 보이지 않는지, 글자 줄 근처에 마침표·쉼표처럼 보이는 점이 없는지(피드 크기 가로 540px 로 줄여서도), 날짜의 1 과 1 이 붙지 않았는지, 2~4장 큰 번호가 01·02·03 인지, 5장이 색면으로 뒤집혔는지, 전 값에만 줄이 그어졌고 그 아래 글자(특히 숫자 8·9·6·3)가 540px 로 줄여서도 읽히는지, 2~4장에 브랜드명이 없는지, 1~4장의 종이색·괘선 자리·서체가 같은지, 종이에 원래 있던 글자·선이 없는지.
3. MP4 이면 `out/frames/` 의 장별 4장(0·0.7·1.4·5.36초)을 본다: 0초는 빈 종이, 0.7초는 괘선이 그어지고 글자가 찍히는 중(2~4장 전 값의 취소선은 아직 없다 — 0.93초부터 그어진다), 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 의 `plateMode`·`fill`·`numberCheck`(최소 잉크 간격·가는 획·기준선)·`specks`·`specks540`·`texture`·`after.size`·`before.strike`·`maxBlankPx`·`warn`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 인스타그램 캐러셀에는 PNG 5장을 순서대로 올린다. MP4 는 장마다 등장 1.4초(괘선·찍힘·롤러 인쇄·줄 긋기) + 분위기 4초(빛 한 번 지나감)다.
폰트(전부 SIL OFL 1.1, 상업 사용 가능): 날짜·번호 Source Serif 4(900 — 굵은 디스플레이 세리프, 글자를 늘이거나 줄이지 않는다), 브랜드·제목·이름·후 Noto Serif KR 700, 전·메모·문의 Noto Serif KR 500.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Claude Code·Codex 같은 코딩 에이전트에 붙여 넣고 브랜드·색·주제·1장~5장 글자를 알려 주세요. 실제 폰트로 5장을 렌더해 글자가 깨지지 않아요.
- 올리기
PNG 5장(원하면 장마다 MP4)을 받아 인스타그램에서 여러 장 선택으로 순서대로 올리세요.
글자 분량
- 1장 · 표지
- 날짜 5자 · 제목 10자
- 2~4장 · 바뀐 항목
- 이름 8자 · 전 12자 · 후 12자 · 메모 20자
- 5장 · 마지막 장
- 적용일 5자 · 한 줄 10자 · 문의 줄 20자
한도를 넘기면 ChatGPT가 그리지 않고 줄여 달라고 되묻습니다.
지킬 것
- 잘 맞는 글: 영업 시간·가격·운영 방식 변경 공지
- 바뀐 내용과 연락처는 실제 값만 넣으세요. 전 값에는 줄이 그어지고 후 값이 크게 찍힙니다.
- 코딩 에이전트(Claude Code·Codex)가 실제 폰트로 렌더해 글자가 깨지지 않습니다. 날짜색은 5장 바탕이 되니 종이색과 대비가 큰 색을 고르세요.