← 캐러셀
캐러셀 · 타이포
마일스톤 리포트
코드 렌더 · 같은 결과
한 해의 숫자를 종이에 찍은 큰 숫자와 값 그대로의 막대로 보여 주세요.
1장1 / 5
가상 브랜드 바람결 책방로 레시피를 실제 실행해 렌더한 5장입니다. 번호나 화살표로 넘겨 보세요.
레시피 보기128,688자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 마일스톤 리포트 캐러셀 5장 (종이에 찍힌 큰 숫자 + 값 비례 막대)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델에게 글자·숫자·막대를 그리게 하지 않는다. 글자 없는 종이 판 한 장 위에 실제 폰트로 숫자·글자를 얹고(레터프레스 잉크·눌린 자국 효과 포함), 막대는 **값에 정확히 비례하게** 코드로 그린다. 헤드리스 브라우저로 5장(PNG, 필요하면 장별 MP4)을 렌더한다. 다섯 장은 한 컴포지션이라 종이·서체·여백·색이 장마다 같다.
- 1장: 초대형 숫자(0 에서 세어 올라감) + 한 줄(제목 크기) + 아래 괘선 위에 **차례 세 줄**(02·03·04 번호 · 2~4장 큰 숫자 · 2~4장 제목 — 2~4장 글자를 그대로 쓰고 새 글자는 만들지 않는다. 표지 아래 빈 곳을 채운다) — 숫자 덩어리는 차례 위 남는 높이의 가운데 + 위 띠(브랜드·작은 정보). 위 띠 기준선은 다섯 장이 같은 높이(코드가 화면에서 재서 맞춘다)
- 2~4장: 데이터 카드 — 큰 숫자 + 제목 + 망점 막대 + 출처. **강조 규칙 하나(세 장 모두, 코드에 고정 — 큰 숫자가 무엇을 가리키는지에 맞춘다)**: ① 큰 숫자 = 막대 값 하나 → 그 막대만 포인트색 ② 큰 숫자 = 막대 합계·평균 → **강조 없음**(모든 막대 같은 망점) + 큰 숫자 옆 작은 `합계`·`평균` ③ 그 외(배수·증가율 등) → 입력 * 막대, 없으면 최댓값 막대. 규칙 결과는 check.json `highlight.rule` 에 남는다. 막대 값 단위가 비면 큰 숫자 단위를 막대 숫자에 붙인다(①②일 때). 막대 최대 높이는 제목 아래 남는 높이만큼(세 장 같은 값) — 제목과 차트 사이가 비지 않는다
- 5장: 감사 한마디(**제목 줄 크기 이하의 머리말** — 잉크 높이가 숫자의 40%, 코드가 정하고 45% 를 넘으면 `hierarchy` 실패) → 다음 목표(1~4장과 **같은 칠한 세리프 숫자**, 이 장의 유일한 주인공) → 한 줄 + 브랜드 — 덩어리를 세로 가운데에
- 인쇄 질감: 종이 결(판, **영역마다 세기가 다름**) + 종이 얼룩(아주 큰 밝기 차이) + 실제 인쇄물 단서(영역마다 다른 잉크 번짐·농도 얼룩, 요소마다 다른 눌림, 포인트색 판 1px 어긋남) — 한 장에 같은 필터를 씌운 것처럼 보이지 않게, 아주 절제해서 코드가 얹는다
- 글자 둘레 판 정리: 글자·막대 상자 + 36px 안의 섬유·얼룩을 모양(점·선) 상관없이 바탕으로 메우고, 피드 크기(540px)로 줄인 화면에서 문장부호처럼 튀는 점이 0 인지 코드가 다시 잰다
- 숫자 조판: 글자마다 잉크 상자를 재서 간격을 맞추고(겹침·붙음 0), 굵은 디스플레이 세리프로 가는 획을 없앤다(어두운 종이에서도 4 가 1 로, 쉼표가 마침표로 읽히지 않는다). 단위는 모든 장에서 숫자 아랫선에 맞춘다. 셋 다 코드가 픽셀로 재서 check.json 에 남긴다
원본 템플릿: https://duchamp.app/readymade/carousels/milestone-report-carousel (바탕이 된 이미지 템플릿: https://duchamp.app/readymade/images/milestone-giant-number · https://duchamp.app/readymade/images/data-paper-chart)
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "종이색, 잉크색, 포인트색" HEX 3개. 비우면 #14213D, #F2EFE6, #C8A24A
주제: (내 브랜드에 맞는 주제 하나) # 무엇을 알리는지. 예: 첫해 회원 수와 이용 기록
넣을 글자: (주제에 맞춰 분량 상한 안에서 장별 문구를 지어 넣는다) # 장마다 한 줄씩
# 작은 정보: 영문 대문자·숫자 2항목(예: SINCE 2014 · MAPO, SEOUL) — 모든 장 위 띠에 나온다
# 1장: 숫자 / 한 줄 숫자는 숫자·쉼표·%·+ 와 단위 한 글자까지 7자 이내, 한 줄 16자 이내(길면 두 줄)
# 2장: 큰 숫자 / 제목 / 값 / 출처
# 3장: 큰 숫자 / 제목 / 값 / 출처
# 4장: 큰 숫자 / 제목 / 값 / 출처
# 큰 숫자는 숫자로 시작 8자 이내(예: 62%, 1,180명), 제목 16자 이내,
# 값은 "라벨 숫자" 쌍 2~7개를 쉼표로(예: 1분기 140명, 2분기 310명, 3분기 520명), 출처 14자 이내
# 값 단위(명·건·병…)는 숫자 뒤에 붙인다. 안 붙이면 큰 숫자의 단위를 쓰고, 큰 숫자가 배수·증가율(배·%p)이면
# 단위를 물어본다(무엇을 센 값인지 모르면 막대 숫자만 덩그러니 남는다)
# 강조(코드가 정한다): 큰 숫자 = 막대 값 하나 → 그 막대 / 큰 숫자 = 막대 합계·평균 → 강조 없이 "합계"·"평균" 표기
# 그 외(배수·증가율 등) → 가장 큰 막대. 그 외 장에서 다른 막대를 강조하고 싶으면 그 막대 값 뒤에 * (예: 2024 180*)
# 큰 숫자가 막대 값·합계·평균인 장에 * 를 붙이면 뜻이 어긋나 검사 실패 — 무시하고 그리지 않는다
# 5장: 감사 / 다음 목표 숫자 / 한 줄 감사 6자 이내(예: 고맙습니다), 숫자 규칙은 1장과 같음
출력: PNG만 # PNG만 | MP4까지
배경판: 자동 # 자동 | 내 사진: <경로> | 예시 판 | ChatGPT 판: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 글자 한도를 넘으면 줄여 달라고 한다. 숫자는 사용자가 실제로 센 값만 쓴다(반올림·추정·값 추가 금지). 브랜드명·넣을 글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 2~4장 출처에도 브랜드명을 넣지 않는다(예: "출처 회원 명부").
- **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)
```
판은 장 수만큼 만들지 않는다. 한 장을 코드가 장마다 조금씩 다른 자리로 잘라 쓴다(같은 종이, 섬유 자리만 다름). 판 색이 브랜드 종이색과 많이 다르면 코드가 판의 결만 빌려 종이색을 바꾼다(`plateMode: tint`).
## 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/milestone-report-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 paper fibres and fine texture, soft low raking light from the left, gentle vignette. Completely blank: absolutely NO text, letters, numbers, lines, shapes, deboss marks, logos or watermarks. Fully opaque.
```
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"info": "<작은 정보>",
"first": {"number": "<1장 숫자, 단위 글자 제외. 예: 3,400>", "unit": "<단위 한 글자, 없으면 빈 문자열>", "line": "<1장 한 줄>"},
"charts": [
{"big": "<2장 큰 숫자 그대로>", "title": "<제목 그대로>", "values": [{"label": "<라벨>", "value": <숫자>}, ...], "unit": "<값 뒤 공통 단위, 없으면 빈 문자열>", "highlight": null, "source": "<출처 그대로>"},
{"big": "<3장>", "...": "..."},
{"big": "<4장>", "...": "..."}
],
"last": {"thanks": "<감사>", "number": "<다음 목표 숫자, 단위 제외>", "unit": "<단위 한 글자>", "line": "<5장 한 줄>"},
"colors": {"paper": "<종이색>", "ink": "<잉크색>", "accent": "<포인트색>"},
"plate": "plates/plate.<확장자>"
}
```
- `highlight`: 입력에 * 가 없으면 `null`(강조 규칙: 같은 값 → 그 막대 / 합계·평균 → 강조 없음 + `합계`·`평균` 표기 / 그 외 → 최댓값 막대). * 가 있으면 그 막대의 0부터 센 번호. 큰 숫자가 막대 값·합계·평균인 장에서 * 가 규칙과 다르면 `highlight-rule` 로 실패한다 → 사용자에게 "* 를 빼기" 또는 "큰 숫자를 그 막대 값으로"를 제안한다. 강조 없음(-1)은 쓰지 않는다(합계·평균 장은 코드가 알아서 강조를 뺀다).
- `values` 는 입력 순서 그대로. `value` 는 쉼표·단위를 뺀 숫자(예: `1,180명` → `1180`, 단위 `명`). 막대 위 숫자는 코드가 `value`+단위로 다시 쓴다(천 단위 쉼표 자동).
- `unit`: 값 뒤 공통 단위. 값에 단위가 없으면 빈 문자열 — 코드가 큰 숫자의 단위(`4,260모` → `모`)를 쓴다(큰 숫자가 막대 값·합계·평균일 때만). 큰 숫자가 배수·증가율이라 단위를 가져올 수 없으면 `unit-missing` 으로 실패 → 사용자에게 무엇을 센 값인지(단위) 묻는다. check.json `unit {input, used, from}`.
- 제목이 한 줄에 안 들어가면 코드가 가운데에 가까운 빈칸에서 두 줄로 나눈다. 끊을 자리를 정하고 싶으면 빈칸 하나를 `\n` 으로 바꿔 넣어도 된다(글자는 그대로).
- 막대 높이 = 값 ÷ 가장 큰 값 × `maxH`(0 기준 선형). `maxH` 는 세 장 중 가장 낮게 끝나는 제목 아래 ~ 기준선 사이 남는 높이(290~560px, 세 장 같음)를 코드가 정한다. 손대지 않는다.
- 숫자 줄(1·5장)은 코드가 좌우 여백(54px) 안에 꽉 차게(최대 640px) 맞추고, 숫자·한 줄(5장은 감사까지) 덩어리를 위 띠와 아래 선 사이 세로 가운데에 놓는다. 단위는 포인트색, 숫자는 잉크색. 5장 감사는 숫자 위의 작은 머리말(제목 줄 크기 이하), 다음 목표 숫자는 1장과 같은 칠한 숫자다. 1장은 차례 세 줄이 아래 괘선 위에 붙고, 숫자 덩어리는 그 위 남는 높이 가운데에 놓인다(`maxBlankPx` ≤ 300, 넘으면 `cover-blank`).
- 숫자 간격은 글자의 실제 잉크 상자 사이를 재서 맞춘다(숫자 사이 0.045em, 쉼표·점 양옆 0.035em, 단위 앞 0.06em). 자간을 손으로 바꾸지 않는다.
- 쪽 번호(01 / 05 …)는 코드가 오른쪽 아래에 붙인다. 브랜드는 1장·5장 위 띠에만 나온다.
## 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/ibm-plex-mono": "5.3.0",
"@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 코드 레시피 · milestone-report-carousel (5장 · 장마다 C 짧게: 등장 1.4s + 분위기 4s) ──
// 1장 = 종이에 찍힌 큰 숫자 · 2~4장 = 같은 종이의 데이터 카드(큰 숫자 + 값 비례 막대) · 5장 = 감사 + 다음 목표(속 빈 숫자)
// 4회차: 5장 숫자 = 1~4장과 같은 칠한 세리프(윤곽 장식 없음) · 위 띠 기준선을 다섯 장 같은 높이에 고정(검사) · 강조 규칙 하나(2~4장 모두 막대 하나, 검사)
// 글자 둘레 36px 안 섬유·얼룩 제거(모양 무관) + 540 축소본 점 검사 · 인쇄 단서(영역마다 다른 잉크 번짐·농도, 요소마다 다른 눌림, 포인트색 판 1px 어긋남)·영역마다 다른 종이 결
// 5회차: 5장 = 고맙습니다(제목 줄 크기) → 큰 숫자(주인공) → 한 줄, 크기 위계 검사 · 1장 = 큰 숫자 + 한 줄 아래에 2~4장 차례(번호·숫자·제목)로 빈 곳 흡수 ·
// 2~4장 막대 높이 = 제목 아래 남는 높이만큼(세 장 같은 비율) · 강조 규칙 기본값을 코드에 고정(같은 값 → 없으면 마지막 막대)
// 6회차: 강조 규칙을 뜻에 맞게(r5 두 검수자 공통 — 합계 4,260모 장에서 마지막(=가장 적은) 주가 금색이라 "마지막 주에 줄었다"로 읽혔다) ·
// 막대 값 단위가 비면 큰 숫자 단위를 막대 라벨에 쓴다(같은 값·합계·평균일 때만), 그래도 없으면 실패(unit-missing → 단위를 묻는다)
// 강조 규칙(모든 2~4장 같음, 코드에 고정, check.json highlight.rule 에 남는다):
// ① same-value: 큰 숫자 = 막대 값 하나(0.5% 이내) → 그 막대(여럿이면 뒤쪽)
// ② sum / mean: 큰 숫자 = 막대 합계·평균(막대 값이 아닌 집계) → **강조 없음**(모든 막대 같은 망점) + 큰 숫자 옆 작은 '합계'·'평균'
// ③ input / max: 그 외(배수·증가율 등) → 입력 * 가 있으면 그 막대, 없으면 최댓값 막대(같으면 뒤쪽)
// 입력 * 가 ①·② 와 어긋나면(①인데 다른 막대, ②인데 * 있음) 검사 실패(highlight-rule) — 무시하고 그리지 않는다. 사용자에게 * 를 빼거나 큰 숫자를 바꾸자고 제안
type Chart = {big: string; title: string; values: {label: string; value: number}[]; unit: string; highlight: number | null; source: string};
type Props = {
slide: number; brand: string; info: string;
first: {number: string; unit: string; line: string};
charts: Chart[];
last: {thanks: string; number: string; unit: string; line: string};
colors: {paper: string; ink: string; accent: string}; plate: string;
};
const defaults: Props = {
slide: 1, brand: '모래알 클라이밍', info: 'EST. 2025 · SEONGSU, SEOUL',
first: {number: '3,400', unit: '명', line: '첫해에 벽을 오른 사람'},
charts: [
{big: '62%', title: '평일 저녁에 가장 많이 왔다', values: [{label: '아침', value: 9}, {label: '낮', value: 29}, {label: '저녁', value: 62}], unit: '%', highlight: null, source: '출처 입장 기록'},
{big: '1,180', title: '다시 온 사람', values: [{label: '1분기', value: 140}, {label: '2분기', value: 310}, {label: '3분기', value: 520}, {label: '4분기', value: 1180}], unit: '명', highlight: null, source: '출처 회원 명부'},
{big: '41개', title: '새로 만든 문제', values: [{label: '봄', value: 8}, {label: '여름', value: 11}, {label: '가을', value: 10}, {label: '겨울', value: 12}], unit: '개', highlight: null, source: '출처 세팅 일지'},
],
last: {thanks: '고맙습니다', number: '5,000', unit: '명', line: '다음 해에 만날 사람'},
colors: {paper: '#14213D', ink: '#F2EFE6', accent: '#C8A24A'}, plate: 'plates/plate.jpg',
};
const M = 54; // 좌우 여백(모든 장)
const NUM = 'Source Serif 4', NUM_W = 900, NUM_ST = 'normal'; // 큰 숫자: 굵은 디스플레이 세리프(가는 획도 두껍다 → 어두운 종이에서도 4·1, 쉼표·마침표가 구분된다). 2회차에 Bodoni Moda 에서 바꿈(Roboto Serif 는 3·4·5·7·9 가 기준선 아래로 내려가 뺐다)
const SIZE_MAX = 640; // 1장·5장 큰 숫자 최대 크기
const HEAD_BASE = 108; // 위 띠(브랜드·작은 정보) 기준선 y — 다섯 장 같음(1·5장에 브랜드가 있어도 줄이 내려가지 않게)
const AREA_TOP = 150, AREA_BOTTOM = 1262, FOOT_TOP = 1288; // 1·5장 숫자 덩어리가 들어갈 세로 구간(가운데 정렬), 쪽 번호
const LINE_SIZE = 92, LINE_LH = 118; // 1·5장 한 줄 = 2~4장 제목과 같은 서체·크기(한 줄에 안 들어가면 두 줄)
// 2~4장 데이터 카드 배치
const BIG_INK_TOP = 150, BIG_SIZE = 372, TITLE_SIZE = 92, TITLE_LH = 118;
const BASE_Y = 1100, CHART_L = M, CHART_R = W - M, LABEL = 30, SOURCE_TOP = 1192, PITCH = 6;
// 5회차: 막대 최대 높이 = 세 장 중 가장 낮게 끝나는 제목 아래 ~ 기준선 사이 남는 높이(값 라벨 + 숨 쉴 자리 CHART_AIR 를 뺀 것, 290~560).
// 4회차까지 290 고정 → 숫자가 짧은 장(46%·9곳)에서 제목–차트 사이 약 220px 빈 띠(r4 검수). 세 장 같은 높이라 막대 비율 기준도 같다
const MAX_H_MIN = 290, MAX_H_MAX = 560, CHART_AIR = 64;
// 5회차: 5장 '고맙습니다' = 제목 줄 크기(LINE_SIZE) — 큰 숫자 하나만 주인공(4회차 두 주인공 → 검수 ①④ 8.5). 위계 검사: 감사 잉크 높이 ≤ 숫자 잉크 높이 × HIER_MAX
const THANKS_GAP = 56, HIER_MAX = 0.45;
// 5회차: 1장 차례 = 2~4장의 큰 숫자·제목을 아래에 세 줄로(표지 아래 40% 빈 곳 흡수, r4 검수 ④). 새 글자를 만들지 않는다(2~4장 글자 그대로)
const IDX_ROW = 112, IDX_NUM = 64, IDX_TITLE = 40, IDX_GAP = 64, COVER_BLANK_MAX = 300;
const FONT_CSS = ['https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@36,900&family=Noto+Serif+KR:wght@500;700;800&family=IBM+Plex+Mono&display=block'];
const MONO = '"IBM Plex Mono", "Noto Serif KR"';
const fmt = (v: number) => v.toLocaleString('en-US', {maximumFractionDigits: 2});
// 장마다 같은 판을 조금씩 다르게 잘라 쓴다(같은 종이, 섬유 자리만 다름)
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 T75 = (x: number) => (x * INTRO) / 75;
const breathe = (f: number) => (1 - Math.cos((2 * Math.PI * f) / LOOP)) / 2;
// 큰 숫자 "53%" "1,250" "4.8배" → 앞 글자·숫자·뒤 글자
const parseBig = (s: string) => {
const m = s.match(/^(\D*?)(\d[\d,]*)(\.\d+)?(.*)$/);
if (!m) return null;
return {pre: m[1], body: m[2] + (m[3] ?? ''), dec: m[3] ? m[3].length - 1 : 0, suf: m[4], value: Number(m[2].replace(/,/g, '') + (m[3] ?? ''))};
};
// 강조 막대 규칙(6회차, 위 머리 주석): 큰 숫자가 막대 하나를 가리키면 그 막대, 집계(합계·평균)면 강조 없이 '합계'·'평균' 표기, 그 외는 입력 * → 최댓값.
// 5회차의 "없으면 마지막 막대"는 합계 장에서 가장 적은 막대를 칠해 뜻이 뒤집혔다(r5 검수 둘 다). 강조 없는 장은 '합계' 표기가 이유를 말한다
type HL = {i: number; why: 'same-value' | 'sum' | 'mean' | 'input' | 'max'; tag: string; conflict: boolean};
const near = (a: number, b: number) => Math.abs(a - b) <= Math.max(1e-9, Math.abs(b) * 0.005);
const autoHighlight = (bigV: number | null, vals: number[], input: number | null | undefined): HL => {
const has = input !== null && input !== undefined;
if (bigV !== null) {
const same = vals.map((v, i) => (near(v, bigV) ? i : -1)).filter((i) => i >= 0);
if (same.length) { const i = same[same.length - 1]; return {i, why: 'same-value', tag: '', conflict: has && input !== i}; }
const sum = vals.reduce((a, b) => a + b, 0);
if (vals.length > 1 && near(sum, bigV)) return {i: -1, why: 'sum', tag: '합계', conflict: has};
if (vals.length > 1 && near(sum / vals.length, bigV)) return {i: -1, why: 'mean', tag: '평균', conflict: has};
}
if (has) { const ok = Number.isInteger(input) && input! >= 0 && input! < vals.length; return {i: ok ? input! : vals.lastIndexOf(Math.max(...vals)), why: ok ? 'input' : 'max', tag: '', conflict: !ok}; }
return {i: vals.lastIndexOf(Math.max(...vals)), why: 'max', tag: '', conflict: false};
};
const chartRule = (c: Chart) => { const b = parseBig(c.big); return autoHighlight(b ? b.value : null, c.values.map((v) => v.value), c.highlight); };
// 막대 값 단위(6회차): 입력 unit, 비었으면 큰 숫자 단위(큰 숫자가 같은 값·합계·평균일 때만 — 배수·증가율의 '배'·'%p' 를 막대에 붙이지 않는다). 그래도 없으면 '' → unit-missing
const chartUnit = (c: Chart) => {
if (c.unit) return c.unit;
const b = parseBig(c.big), r = chartRule(c);
return b && (r.why === 'same-value' || r.why === 'sum' || r.why === 'mean') ? b.suf.trim() : '';
};
const TAG_SIZE = 40, TAG_LH = 52, TAG_GAP = 22; // '합계'·'평균' 표기: 큰 숫자 오른쪽, 잉크 윗끝을 숫자 잉크 윗끝에 맞춤
const TAG_FONT = `700 ${TAG_SIZE}px "Noto Serif KR"`;
// 숫자 줄 조판: 숫자·쉼표는 NUM, 한글 단위는 Noto Serif KR 800(0.42배), % 같은 기호 단위는 NUM(0.6배). 단위는 포인트색, 잉크 아랫끝을 숫자에 맞춘다
const numberGlyphs = (pre: string, body: string, suf: string, D: number, stroke = 0): GlyphIn[] => {
const unit = (c: string): GlyphIn => (/[^\x00-\x7F]/.test(c) ? {c, family: 'Noto Serif KR', weight: 800, size: D * 0.42, role: 'unit'} : {c, family: NUM, weight: NUM_W, stretch: NUM_ST, size: D * 0.6, role: 'unit', stroke: stroke ? stroke * 0.6 : 0});
return [
...[...pre].map((c): GlyphIn => ({c, family: NUM, weight: NUM_W, stretch: NUM_ST, size: D * 0.6, role: 'mark'})),
...[...body].map((c): GlyphIn => ({c, family: NUM, weight: NUM_W, stretch: NUM_ST, size: D, role: /\d/.test(c) ? 'digit' : 'mark', stroke})),
...[...suf].filter((c) => c !== ' ').map(unit),
];
};
// 잉크 간격: 숫자-숫자 0.045em, 쉼표·점 양옆 0.035em, 단위 앞 0.06em(숫자 크기 기준). 검사 하한은 0.025em
const numberGap = (D: number) => (a: GlyphIn, b: GlyphIn) => (b.role === 'unit' && a.role !== 'unit' ? 0.06 : a.role === 'mark' || b.role === 'mark' ? 0.035 : 0.045) * D;
// 폭 maxW 안에 들어가는 가장 큰 크기(최대 maxD)로 조판(두 번 맞춰 수렴)
const fitNumber = (pre: string, body: string, suf: string, maxW: number, maxD: number, hollow: boolean) => {
let D = maxD;
for (let k = 0; k < 3; k++) {
const w = setInk(numberGlyphs(pre, body, suf, D), numberGap(D)).width;
if (w <= maxW + 0.5) break;
D = D * (maxW / w) * 0.995;
}
// 속 빈 숫자(5장)는 칠한 글자와 같은 모양·자리(겉 윤곽 = 글자 윤곽). 윤곽 두께만 정한다 — 화면에서는 SVG feMorphology 로 글자 안쪽을 깎는다
// (2회차의 -webkit-text-stroke 는 가변 글꼴의 겹친 윤곽선을 따로 그려 1·2·쉼표 안에 교차선이 남았다)
const st = hollow ? Math.max(7, D * 0.026) : 0;
const row = alignUnits(setInk(numberGlyphs(pre, body, suf, D), numberGap(D)).glyphs);
const capT = Math.max(...row.filter((g) => g.role === 'digit').map((g) => g.ink.t));
return {D, row, capT, width: Math.max(...row.map((g) => g.x + g.ink.r)), stroke: st};
};
type NumLay = ReturnType<typeof fitNumber>;
// 한 줄에 안 들어가면 가운데에 가장 가까운 빈칸에서 두 줄로(\n 이 있으면 그대로)
const splitTitle = (t: string, tw: (s: string) => number, maxW: number) => {
if (t.includes('\n')) return t.split('\n');
if (tw(t) <= maxW) return [t];
const sp = [...t].map((c, i) => (c === ' ' ? i : -1)).filter((i) => i > 0);
if (!sp.length) return [t];
const cut = sp.reduce((a, b) => (Math.abs(tw(t.slice(0, b)) - tw(t.slice(b + 1))) < Math.abs(tw(t.slice(0, a)) - tw(t.slice(a + 1))) ? b : a));
return [t.slice(0, cut), t.slice(cut + 1)];
};
// 세어 올라가는 중인 숫자는 자리(최종 글자의 잉크 가운데)에 맞춰 가운데 놓는다
const centerIn = (g: Placed, c: string) => {
if (c === g.c) return g.x;
const ink = rasterGlyph({...g, c}).ink;
return g.x + (g.ink.l + g.ink.r) / 2 - (ink.l + ink.r) / 2;
};
const Report: React.FC<Props> = (p) => {
const n = p.slide;
const ch = n >= 2 && n <= 4 ? p.charts[n - 2] : null;
const [handle] = useState(() => delayRender('fonts+fit'));
const [fiber, setFiber] = useState<string | null>(null);
const [plateUrl, setPlateUrl] = useState<string | null>(null);
const [headFix, setHeadFix] = useState<number[]>([0, 0]);
const root = React.useRef<HTMLDivElement>(null);
const [look, setLook] = useState<ReturnType<typeof plateLook> | null>(null);
const [lay, setLay] = useState<{num: NumLay | null; base: number; titleTop: number; lineTop: number; thanks: number; thanksTop: number; lines: string[]; tScale: number; lScale: number; sScale: number; lineScale: number; maxH: number; idxTop: number; idxCol: number; idxScale: number; tagTop: number}>({num: null, base: 0, titleTop: 0, lineTop: 0, thanks: 1, thanksTop: 0, lines: [ch?.title ?? ''], tScale: 1, lScale: 1, sScale: 1, lineScale: 1, maxH: MAX_H_MIN, idxTop: 0, idxCol: 0, idxScale: 1, tagTop: 0});
const [fix, setFix] = useState<number[]>([]);
const vals = ch?.values ?? [];
const nb = vals.length;
const big = ch ? parseBig(ch.big) : null;
const auto: HL = ch ? chartRule(ch) : {i: -1, why: 'max', tag: '', conflict: false};
const hi = ch ? auto.i : -1; // 화면 강조는 언제나 규칙 막대(-1 = 집계 장, 강조 없음). 입력이 규칙과 어긋나면 검사 실패로 알린다
const unit = ch ? chartUnit(ch) : '';
const maxV = Math.max(1e-9, ...vals.map((v) => v.value));
const barW = Math.min(118, ((CHART_R - CHART_L - 44) / Math.max(1, nb)) * 0.66);
const step = nb > 1 ? (CHART_R - CHART_L - 44 - barW) / (nb - 1) : 0;
const barX = (i: number) => CHART_L + 22 + i * step;
const numberRow = n === 1 ? p.first : n === 5 ? p.last : null;
useEffect(() => {
(async () => {
checkSlide(p);
if (p.charts.length !== 3) throw new Error('charts 는 3개(2~4장)');
if (ch && !big) throw new Error(`${n}장 큰 숫자는 숫자로 시작해야 한다: ` + ch.big);
if (ch && (nb < 2 || nb > 7)) throw new Error(`${n}장 값은 2~7개`);
if (ch && (vals.some((v) => !(v.value >= 0)) || !(Math.max(...vals.map((v) => v.value)) > 0))) throw new Error('값은 0 이상 숫자, 최댓값은 0보다 커야 한다');
const allLabels = p.charts.flatMap((c) => c.values.flatMap((v) => [v.label, fmt(v.value) + chartUnit(c)])).join('');
await loadFonts(FONT_CSS, [
[`${NUM_W} 100px "${NUM}"`, '0123456789,.%+' + p.charts.map((c) => c.big).join('').replace(/[^\x00-\x7F]/g, '')],
['800 100px "Noto Serif KR"', p.first.unit + p.last.unit + p.last.thanks + p.charts.map((c) => c.big).join('').replace(/[\x00-\x7F]/g, '')],
['700 46px "Noto Serif KR"', p.brand + p.last.thanks + p.charts.map((c) => c.title).join('') + '합계평균'],
['500 70px "Noto Serif KR"', p.first.line + p.last.line + p.charts.map((c) => c.source + c.title).join('') + allLabels.replace(/[\x00-\x7F]/g, '')],
['23px "IBM Plex Mono"', p.info + '0123456789/ ' + allLabels.replace(/[^\x00-\x7F]/g, '')],
]);
const pa = await analysePlate(staticFile(p.plate));
const lk = plateLook(pa.mean, pa.lum, p.colors.paper);
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 = W - 2 * M;
const over: string[] = [];
let extra: Record<string, unknown> = {};
const warn: string[] = [];
if (numberRow) {
// 1·5장: 숫자 줄을 좌우 여백 안에 가장 크게(최대 560px) → 숫자·한 줄(5장은 감사 + 숫자 + 한 줄) 덩어리를 세로 가운데에
const num = fitNumber('', numberRow.number, numberRow.unit, maxW, SIZE_MAX, false); // 5장도 1장과 같은 칠한 숫자(3회차 윤곽 숫자는 앞 네 장과 결이 달랐다)
const lw = (t: string) => mw(t, `700 ${LINE_SIZE}px "Noto Serif KR"`) + 4;
const lines = splitTitle(numberRow.line, lw, maxW);
const lineScale = Math.min(1, maxW / Math.max(...lines.map(lw)));
// 5장 감사 = 제목 줄 크기(LINE_SIZE·700, 한 줄) — 숫자 위의 머리말. 줄 상자 LINE_LH, 숫자 잉크 윗끝까지 THANKS_GAP
// 크기 = min(제목 줄 크기, 폭, 감사 잉크 높이가 숫자 잉크 높이의 40% 가 되는 크기) — 숫자가 길어 작아지면 감사도 함께 작아진다(위계 유지)
const th100 = inkBox(p.last.thanks, '700 100px "Noto Serif KR"', 100, 128).h / 100;
const thanks = n === 5 ? Math.min(1, maxW / (mw(p.last.thanks, `700 ${LINE_SIZE}px "Noto Serif KR"`) + 4), (num.capT * 0.4) / th100 / LINE_SIZE) : 1;
const thInk = inkBox(p.last.thanks, `700 ${LINE_SIZE * thanks}px "Noto Serif KR"`, LINE_SIZE * thanks, LINE_LH * thanks);
const thanksH = n === 5 ? thInk.bottom + THANKS_GAP : 0; // 줄 상자 위 ~ 감사 잉크 아래 + 간격
const descent = Math.max(0, ...num.row.map((q) => q.ink.b + q.dy)); // 쉼표 꼬리 등 기준선 아래
const blockH = thanksH + num.capT + descent + 40 + lines.length * LINE_LH * lineScale;
// 1장 차례: 아래 괘선 위에 2~4장 세 줄(번호 · 큰 숫자 · 제목). 숫자 덩어리는 차례 위 남는 높이의 가운데
const idxH = n === 1 ? 3 * IDX_ROW : 0;
const idxTop = AREA_BOTTOM - idxH;
const areaB = n === 1 ? idxTop - IDX_GAP : AREA_BOTTOM;
const idxCol = Math.max(...p.charts.map((c) => mw(c.big, `${NUM_W} ${IDX_NUM}px "${NUM}", "Noto Serif KR"`))) + 40;
const idxScale = Math.min(1, ...p.charts.map((c) => (maxW - 92 - idxCol) / (mw(c.title, `500 ${IDX_TITLE}px "Noto Serif KR"`) + 4)));
const top = AREA_TOP + Math.max(0, areaB - AREA_TOP - blockH) / 2;
const base = top + thanksH + num.capT;
setLay((o) => ({...o, num, base, lineTop: base + descent + 40, thanks, thanksTop: top, lineScale, lines, idxTop, idxCol, idxScale}));
await settle();
let idxScaleNow = idxScale;
if (n === 1) {
// 차례 숫자 칸 폭은 화면에서 다시 잰다(한글 단위는 대체 글꼴이라 캔버스 폭과 다르다) → 숫자 칸 + 56px 뒤에 제목
const col = Math.max(...[...document.querySelectorAll<HTMLElement>('[data-ixv]')].map((el) => el.getBoundingClientRect().width * (W / Math.max(1, root.current!.getBoundingClientRect().width)))) + 56;
const sc = Math.min(1, ...p.charts.map((c) => (maxW - 92 - col) / (mw(c.title, `500 ${IDX_TITLE}px "Noto Serif KR"`) + 4)));
idxScaleNow = sc;
setLay((o) => ({...o, idxCol: col, idxScale: sc}));
await settle();
}
const nc = numberCheck('number', num.row, num.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 (num.D < 200) over.push('number');
if (n === 5 && thanks < 0.6) over.push('thanks');
// 크기 위계(5회차): 5장 감사 잉크 높이 ≤ 숫자 잉크 높이 × 0.45, 감사 글자 크기 ≤ 제목 줄 크기 — 주인공은 숫자 하나
const numInkH = num.capT; // 숫자 잉크 높이(기준선 위)
const hier = n === 5 ? +(thInk.h / numInkH).toFixed(3) : null;
if (n === 5 && (hier! > HIER_MAX || LINE_SIZE * thanks > LINE_SIZE + 0.5)) over.push('hierarchy');
// 빈 띠(5회차): 위 띠 아래(AREA_TOP) ~ 아래 괘선 사이에서 글자 잉크 사이 가장 큰 빈 높이. 1장은 COVER_BLANK_MAX 를 넘으면 실패
const ys = textRects(root.current).filter((r) => r.y > AREA_TOP - 20 && r.y < AREA_BOTTOM).map((r) => [r.y, r.y + r.h] as [number, number]).sort((a, b) => a[0] - b[0]);
let cov = AREA_TOP, blank = 0;
for (const [a, b] of ys) { if (a > cov) blank = Math.max(blank, a - cov); cov = Math.max(cov, b); }
blank = Math.round(Math.max(blank, AREA_BOTTOM - cov));
if (n === 1 && blank > COVER_BLANK_MAX) over.push('cover-blank');
if (n === 1 && idxScaleNow < 0.7) over.push('index');
if (lineScale < 0.7) over.push('line');
if (lines.length > 2) over.push('line-lines');
extra = {numberSize: Math.round(num.D), numberStyle: {family: num.row[0].family, weight: num.row[0].weight, filled: true}, numberCheck: {...nc.report, domAdvanceErrPx: dm, domBaselineFixPx: bl.before, domBaselineErrPx: bl.after}, emptyBand: Math.round(Math.max(top - AREA_TOP, 0)), maxBlankPx: blank, ...(n === 1 ? {index: {top: Math.round(idxTop), scale: +idxScaleNow.toFixed(3)}} : {}), ...(n === 5 ? {thanks: {size: Math.round(LINE_SIZE * thanks), inkH: Math.round(thInk.h), numberInkH: Math.round(numInkH), ratio: hier, max: HIER_MAX}} : {})};
} else if (ch) {
// 제목: 한 줄에 안 들어가면 가운데에 가장 가까운 빈칸에서 두 줄로(\n 이 있으면 그대로)
const tw = (s: string) => mw(s, `700 ${TITLE_SIZE}px "Noto Serif KR"`) + 4;
const lines = splitTitle(ch.title, tw, maxW);
const tScale = Math.min(1, maxW / Math.max(...lines.map(tw)));
const avail = Math.min(barW + 70, (nb > 1 ? step : barW + 70) - 12);
const lw = Math.max(...vals.flatMap((v) => [mw(v.label, `${LABEL}px ${MONO}`), mw(fmt(v.value) + unit, `${LABEL}px ${MONO}`)]));
const lScale = Math.min(1, avail / lw);
const sScale = Math.min(1, maxW / mw(ch.source, '500 36px "Noto Serif KR"'));
// 큰 숫자 폭: 오른쪽에 24px 숨 쉴 자리(단위가 여백에 닿지 않게). 집계 장은 '합계'·'평균' 표기 자리도 뺀다
const bigW = (c: Chart) => { const r = chartRule(c); return maxW - 24 - (r.tag ? mw(r.tag, TAG_FONT) + TAG_GAP : 0); };
const num = fitNumber(big!.pre, big!.body, big!.suf, bigW(ch), BIG_SIZE, false);
const base = BIG_INK_TOP + num.capT;
const tagTop = auto.tag ? base - num.capT - inkBox(auto.tag, TAG_FONT, TAG_SIZE, TAG_LH).top : 0; // 표기 잉크 윗끝 = 숫자 잉크 윗끝
// 제목 자리(4회차): 큰 숫자의 기준선 아래 잉크(쉼표 꼬리)만큼 내린다 — 4회차 Codex 실행에서 "1,180" 쉼표 꼬리가 제목 "한"에 닿았다.
// 세 장 중 가장 깊은 꼬리로 정해 2~4장 제목 줄 높이가 같다(장마다 줄이 튀지 않게)
const deepest = Math.max(...p.charts.map((c) => { const b = parseBig(c.big); if (!b) return 0; const nl = fitNumber(b.pre, b.body, b.suf, bigW(c), BIG_SIZE, false); return BIG_INK_TOP + nl.capT + Math.max(0, ...nl.row.map((q) => q.ink.b + q.dy)); }));
const titleTop = Math.max(base + 34, deepest + 6);
// 막대 최대 높이(5회차): 세 장 중 제목이 가장 낮게 끝나는 자리 기준(세 장 같은 값) → 제목–차트 사이 빈 띠를 막대가 흡수
const maxLines = Math.max(...p.charts.map((c) => splitTitle(c.title, tw, maxW).length));
const lowestTitle = titleTop + maxLines * TITLE_LH;
const maxH = Math.round(Math.max(MAX_H_MIN, Math.min(MAX_H_MAX, BASE_Y - lowestTitle - 14 - LABEL * 1.1 - CHART_AIR)));
setLay((o) => ({...o, num, base, lines, tScale, lScale, sScale, titleTop, maxH, tagTop}));
await settle();
const nc = numberCheck('big', num.row, num.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');
const titleBottom = titleTop + lines.length * TITLE_LH * tScale;
// 큰 숫자 잉크 아랫끝(쉼표 꼬리 포함)과 제목 첫 줄 잉크 윗끝 사이 ≥ 12px
const tInk = inkBox(lines[0], `700 ${TITLE_SIZE * tScale}px "Noto Serif KR"`, TITLE_SIZE * tScale, TITLE_LH * tScale);
const numBottom = base + Math.max(0, ...num.row.map((q) => q.ink.b + q.dy));
const numTitleGap = Math.round(titleTop + tInk.top - numBottom);
if (numTitleGap < 12) over.push('title-number');
if (lines.length > 2) over.push('title-lines');
if (tScale < 0.7) over.push('title');
const chartTop = BASE_Y - maxH - 14 - LABEL * 1.1; // 가장 높은 막대의 값 라벨 윗끝
if (titleBottom > chartTop - 24) over.push('title-chart');
const titleChartGap = Math.round(chartTop - titleBottom);
if (lScale < 0.6) over.push('labels');
if (sScale < 0.7) over.push('source');
if (num.D < BIG_SIZE * 0.5) over.push('big');
const bars = [...document.querySelectorAll<SVGRectElement>('[data-bar]')].map((r, i) => ({label: vals[i].label, value: vals[i].value, px: Math.round(r.getBBox().height * 10) / 10, expected: Math.round((vals[i].value / maxV) * maxH * 10) / 10}));
const maxErr = Math.max(...bars.map((b) => Math.abs(b.px - b.expected)));
if (maxErr > 0.6) over.push('bar-ratio');
// 강조 검사(6회차): 규칙 결과(same-value·sum·mean·input·max)를 남기고, 화면 포인트색 막대 수 = 규칙(막대 하나 또는 집계 장 0개). 입력 * 가 규칙과 어긋나면 실패
if (auto.conflict) {
over.push('highlight-rule');
const inLabel = Number.isInteger(ch.highlight) && ch.highlight! >= 0 && ch.highlight! < nb ? vals[ch.highlight!].label : String(ch.highlight);
const why = auto.why === 'same-value' ? `큰 숫자와 같은 값의 막대 = ${vals[auto.i].label}` : auto.why === 'sum' || auto.why === 'mean' ? `큰 숫자가 막대 ${auto.tag}라 강조 없음` : '입력 번호가 막대 범위 밖';
warn.push(`highlight-rule: 입력 강조 = ${inLabel}, 규칙 = ${why} → * 를 빼거나(규칙대로) 큰 숫자를 강조하고 싶은 막대 값으로 바꿀 것을 사용자에게 제안`);
}
const accentBars = [...document.querySelectorAll<SVGRectElement>('[data-bar]')].filter((r) => r.getAttribute('fill') === p.colors.accent).length;
const expectBars = hi >= 0 ? 1 : 0;
if (accentBars !== expectBars) over.push('highlight-count');
// '합계'·'평균' 표기: 집계 장에만, 큰 숫자 오른쪽(겹침 없이)·오른쪽 여백 안
let tag: Record<string, unknown> | null = null;
if (auto.tag) {
const te = document.querySelector<HTMLElement>('[data-tag]');
const sc = W / Math.max(1, root.current!.getBoundingClientRect().width);
const tr = te ? te.getBoundingClientRect() : null;
const right = tr ? Math.round((tr.right - root.current!.getBoundingClientRect().left) * sc) : 9999;
const numRight = M + Math.max(...num.row.map((q) => q.x + q.ink.r));
const leftX = M + num.width + TAG_GAP;
tag = {text: auto.tag, left: Math.round(leftX), right, gapPx: Math.round(leftX - numRight)};
if (!te || right > W - M + 1 || leftX - numRight < 12) over.push('tag');
} else if (document.querySelector('[data-tag]')) over.push('tag');
// 막대 값 단위(6회차): 입력이 비면 큰 숫자 단위, 그래도 없으면 실패 → 사용자에게 단위를 묻는다
if (!unit) {
over.push('unit-missing');
warn.push(`unit-missing: ${n}장 막대 값 단위가 비어 있고 큰 숫자(${ch.big})에서도 가져올 수 없다 → 막대 값이 무엇을 센 것인지 단위(예: 건·명·병)를 사용자에게 묻는다`);
}
extra = {titleLines: lines, titleTop: Math.round(titleTop), chart: {maxH, titleChartGapPx: titleChartGap}, numTitleGapPx: numTitleGap, bigSize: Math.round(num.D), numberCheck: {...nc.report, domAdvanceErrPx: dm, domBaselineFixPx: bl.before, domBaselineErrPx: bl.after}, scale: {title: +tScale.toFixed(3), labels: +lScale.toFixed(3), source: +sScale.toFixed(3)}, unit: {input: ch.unit, used: unit, from: ch.unit ? 'input' : unit ? 'big' : 'none'}, highlight: {index: hi, label: hi >= 0 ? vals[hi].label : null, rule: auto.why, tag: auto.tag || null, input: ch.highlight ?? null, conflict: auto.conflict, accentBars, expectedAccentBars: expectBars, ...(tag ? {tagBox: tag} : {}), allSlides: p.charts.map((c, j) => { const r = chartRule(c); return {slide: j + 2, label: c.values[r.i]?.label ?? null, rule: r.why, tag: r.tag || null}; })}, bars, barMaxErrorPx: maxErr};
}
// 위 띠: 브랜드(1·5장)와 작은 정보가 겹치는지, 넘침
[...document.querySelectorAll<HTMLElement>('[data-fit]')].forEach((el) => { if (el.scrollWidth > el.clientWidth + 1) over.push(el.dataset.fit!); });
const heads = [...document.querySelectorAll<HTMLElement>('[data-head]')];
if (heads.length === 2) {
const [a, b] = heads.map((c) => c.getBoundingClientRect());
if (a.right + 24 > b.left) over.push('brand+info');
}
// 위 띠 기준선 고정(4회차): 브랜드·작은 정보의 화면 기준선을 재서 HEAD_BASE 에 맞춘다(1·5장 = 브랜드 있음, 2~4장 = 정보만 — 줄 높이가 달라도 같은 자리)
const headBase = () => heads.map((el) => { const m = el.querySelector<HTMLElement>('[data-bl]'); return m ? el.offsetTop + m.offsetTop : HEAD_BASE; });
const hb0 = headBase();
setHeadFix(hb0.map((y) => HEAD_BASE - y));
await settle();
const hb1 = headBase();
const headErr = Math.max(0, ...hb1.map((y) => Math.abs(y - HEAD_BASE)));
if (headErr > 1) over.push('head-y');
// 글자 둘레 판 정리(4회차): 글자·막대 상자 + 36px 안의 섬유·얼룩(점·선 모양 무관)을 바탕으로 메우고, 섬유 결은 영역마다 세기를 달리한다
const rects = textRects(root.current);
const cp = await cleanPlateFor540(staticFile(p.plate), crop, rects, gain, lk, p.colors.paper, 0.85, 11 + n * 7);
const s540 = cp.specks540;
setLook(lk); setPlateUrl(cp.plate); setFiber(cp.fiber);
await settle();
if (cp.specks.after > 0) over.push('plate-specks');
if (s540.count > 0) over.push('specks-540');
if (cp.texture.cv < 0.2) over.push('texture-uniform');
extra = {...extra, head: {baseline: HEAD_BASE, measured: hb1.map((y) => Math.round(y * 10) / 10), errPx: Math.round(headErr * 10) / 10}, specks: cp.specks, specks540: s540, texture: cp.texture};
const leaks = ch ? brandLeaks(p.brand, [ch.big, ch.title, ch.source, p.info, ...vals.map((v) => v.label)]) : [];
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|baseline|font)$|head-y|plate-specks|specks-540|texture-uniform|highlight-count|^tag$/.test(o)) ? ' / 숫자 조판·위 띠·판 점·질감·표기 자리 검사 실패는 코드 문제다 → 고치지 말고 check.json 을 그대로 보고' : ' → 사용자에게 줄이거나 고친 안을 물어본 뒤 props.json 을 고쳐 다시') : '';
report({slide: n, ok: over.length === 0, overflow: over, ...(warn.length ? {warn} : {}), plateMode: lk.mode, ...extra, hint});
await settle();
continueRender(handle);
})().catch((e) => cancelRender(e));
}, []);
const frame = useCurrentFrame();
const f = frame - INTRO;
const ink = p.colors.ink, accent = p.colors.accent;
// 눌림: 위 그림자·아래 빛 — 요소(k)마다 세기가 0.7~1.3배로 달라 한 장 전체가 고르게 눌려 보이지 않는다(4회차)
const pressAt = (k: number): React.CSSProperties => { const q = pressK(n, k); return {filter: `url(#print) drop-shadow(0 ${(-1.5 * q).toFixed(2)}px 0.5px rgba(0,0,0,${(0.45 * q).toFixed(2)})) drop-shadow(0 ${(1.5 * q).toFixed(2)}px 0.5px rgba(255,255,255,${(0.1 * q).toFixed(2)}))`}; };
const reg = misreg(ink, n); // 포인트색(두 번째 판) 1px 어긋남
const topLine = ramp(frame, 0, T75(34));
const topText = enter(frame, T75(10), T75(28));
const light = breathe(f);
const crop = CROPS[n - 1] ?? CROPS[0];
// 위 띠 글자: 기준선 = HEAD_BASE(화면에서 재서 맞춘다, headFix). 줄 상자 = 글꼴 ascent+descent
const headStyle = (family: string, weight: number, size: number, fix: number): React.CSSProperties => {
const x = document.createElement('canvas').getContext('2d')!; x.font = `${weight} ${size}px "${family}"`;
const m = x.measureText('Hg가'), asc = m.fontBoundingBoxAscent, desc = m.fontBoundingBoxDescent;
return {position: 'absolute', top: HEAD_BASE - asc + fix, height: asc + desc, lineHeight: `${asc + desc}px`, fontFamily: `"${family}"`, fontWeight: weight, fontSize: size, whiteSpace: 'pre'};
};
// 모든 장 공통 틀: 판(장마다 다른 자리) + 위 선 + 위 띠 + 쪽 번호 + 섬유·측광·비네트
const frameTop = (
<>
<div style={{position: 'absolute', left: M, right: M, top: 50, height: 2, background: ink, opacity: 0.8, transformOrigin: '0 50%', transform: `scaleX(${topLine})`, ...pressAt(1)}} />
<div style={{position: 'absolute', left: M, right: M, top: AREA_BOTTOM + 8, height: 2, background: ink, opacity: 0.8, transformOrigin: '100% 50%', transform: `scaleX(${topLine})`, ...pressAt(2)}} />
<div style={{position: 'absolute', inset: 0, color: ink, opacity: 1 - topText, transform: `translateY(${topText * 18}px)`}}>
{(n === 1 || n === 5) && <span data-head data-t style={{...headStyle('Noto Serif KR', 700, 46, headFix[0] ?? 0), left: M, letterSpacing: '-0.01em', ...pressAt(3)}}>{p.brand}<BL /></span>}
<span data-head data-t style={{...headStyle('IBM Plex Mono', 400, 23, headFix[n === 1 || n === 5 ? 1 : 0] ?? 0), right: M, letterSpacing: '0.12em', ...pressAt(4)}}>{p.info}<BL /></span>
</div>
</>
);
const foot = <div data-t style={{position: 'absolute', right: M, top: FOOT_TOP, fontFamily: 'IBM Plex Mono', fontSize: 22, letterSpacing: '0.12em', color: ink, opacity: 0.75 * (1 - topText), ...pressAt(5)}}>{pageLabel(n)}</div>;
const finish = (
<>
{/* 섬유 overlay: 글자 둘레를 정리한 판에서 만들고, 영역마다 세기가 다르다(cleanPlate). 크롭은 이미 판에 들어 있다 */}
{fiber && <Img src={fiber} style={{position: 'absolute', width: W, height: H, mixBlendMode: 'overlay', opacity: 0.85}} />}
{/* 종이 얼룩: 아주 낮은 주파수의 밝기 차이(눅눅한 자국·고르지 않은 종이) — 장마다 자리가 다르다 */}
<PaperMottle seed={7 + n} opacity={0.35} />
<div style={{position: 'absolute', inset: 0, mixBlendMode: 'soft-light', background: `radial-gradient(70% 60% at ${18 + light * 64}% ${30 + light * 10}%, rgba(255,250,235,0.10), rgba(255,250,235,0) 70%)`}} />
<div style={{position: 'absolute', inset: 0, background: 'radial-gradient(120% 90% at 50% 45%, rgba(0,0,0,0) 55%, rgba(0,0,0,0.18) 100%)'}} />
<Grain opacity={0.05} />
</>
);
// 인쇄 단서(4회차, common PrintDefs): 가장자리 거칠기 · 영역마다 다른 잉크 번짐 · 잉크 농도 얼룩. 눌림 = pressAt, 판 어긋남 = reg
const svgInk = <PrintDefs seed={5 + n} />;
const bg = look && plateUrl && <Img src={plateUrl} style={look.style} />;
const num = lay.num;
if (numberRow) {
// 1장(숫자 카운트업) · 5장(다음 목표 = 속 빈 숫자, 카운트 없이 자리마다 떨어져 앉음)
const target = Number(numberRow.number.replace(/[^0-9]/g, ''));
const digitCount = [...numberRow.number].filter((c) => /\d/.test(c)).length;
const hollow = n === 5; // 5장: 카운트 없이 자리마다 떨어져 앉음(모양은 1장과 같은 칠한 숫자)
const v = hollow ? target : Math.round(target * ramp(frame, T75(14), T75(68)));
const dipAt = (fr: number, col: number) => enter(fr, T75((hollow ? 20 : 4) + col * 3), T75(22));
let di = 0;
return (
<AbsoluteFill ref={root} style={{backgroundColor: p.colors.paper, overflow: 'hidden'}}>
{bg}{svgInk}{frameTop}
{n === 5 && num && (
<div data-fit="thanks" data-t style={{position: 'absolute', left: M, right: M, top: lay.thanksTop, height: LINE_LH * lay.thanks, overflow: 'hidden'}}>
<div style={{fontFamily: 'Noto Serif KR', fontWeight: 700, fontSize: LINE_SIZE * lay.thanks, lineHeight: `${LINE_LH * lay.thanks}px`, color: ink, whiteSpace: 'pre', letterSpacing: '-0.01em', transform: `translateY(${enter(frame, T75(6), T75(30)) * (LINE_LH + 2)}px)`, ...pressAt(6)}}>{p.last.thanks}</div>
</div>
)}
{num && (
<div data-row style={{position: 'absolute', left: M, top: 0, width: num.width, height: H, ...pressAt(7)}}>
{num.row.map((g, i) => {
if (g.role === 'digit') {
const idx = di++;
const place = 10 ** (digitCount - 1 - idx);
const c = String(Math.floor(v / place) % 10);
const dip = dipAt(frame, idx);
const blur = Math.min(22, Math.abs(dip - dipAt(Math.max(0, frame - 1), idx)) * g.size * 0.18);
return (
<span key={i} data-t data-glyph={glyphData(g, lay.base)} style={{...glyphStyle(g, 0, lay.base, fix[i] ?? 0), left: centerIn(g, c), clipPath: `inset(-10% -40% ${Math.max(0, g.desc - g.size * 0.03)}px -40%)`, color: ink, opacity: place > 1 && v < place && v < target ? 0.22 : 1}}>
<span style={{display: 'block', transform: `translateY(${dip * g.size * 1.05}px)`, filter: blur > 0.4 ? `blur(${(blur / 3).toFixed(2)}px)` : undefined}}>{c}<BL /></span>
</span>
);
}
// 쉼표·점(6회차): 숫자처럼 자리 아래에서 올라오되 잘리지 않으므로 올라오는 만큼 나타난다 — 0초에 쉼표 혼자 떠 있던 것(r6 영상 확인)
const left = Math.max(0, di - 1);
const leftPlace = 10 ** (digitCount - 1 - left);
const isUnit = g.role === 'unit';
return (
<span key={i} data-t data-glyph={glyphData(g, lay.base)} style={{...glyphStyle(g, 0, lay.base, fix[i] ?? 0), color: isUnit ? accent : ink, filter: isUnit ? reg : undefined,
opacity: isUnit ? 1 - enter(frame, T75(46), T75(22)) : (v < leftPlace && v < target ? 0.22 : 1) * (1 - dipAt(frame, left))}}><span style={{display: 'block', transform: isUnit ? undefined : `translateY(${dipAt(frame, left) * g.size * 0.3}px)`}}>{g.c}<BL /></span></span>
);
})}
</div>
)}
{n === 1 && num && p.charts.map((c, i) => {
// 1장 차례(5회차): 번호(2~4장 쪽) · 큰 숫자 · 제목 — 가는 괘선 위에 한 줄씩. 숫자는 글자색(포인트색은 큰 숫자 단위에만)
const y = lay.idxTop + i * IDX_ROW, op = 1 - enter(frame, T75(40 + i * 6), T75(22));
return (
<div key={'ix' + i} style={{position: 'absolute', left: M, top: y, width: W - 2 * M, height: IDX_ROW, opacity: op}}>
<div style={{position: 'absolute', left: 0, right: 0, top: 0, height: 1.5, background: ink, opacity: 0.45, filter: 'url(#print)'}} />
<div data-t style={{position: 'absolute', left: 0, top: 0, lineHeight: `${IDX_ROW}px`, fontFamily: 'IBM Plex Mono', fontSize: 24, letterSpacing: '0.08em', color: ink, opacity: 0.7, whiteSpace: 'pre', ...pressAt(40 + i)}}>{String(i + 2).padStart(2, '0')}</div>
<div data-t style={{position: 'absolute', left: 92, top: 0, lineHeight: `${IDX_ROW}px`, fontFamily: `"${NUM}", "Noto Serif KR"`, fontWeight: NUM_W, fontSize: IDX_NUM, color: ink, whiteSpace: 'pre', ...pressAt(43 + i)}}><span data-ixv>{c.big}</span></div>
<div data-t style={{position: 'absolute', left: 92 + lay.idxCol, top: 0, lineHeight: `${IDX_ROW}px`, fontFamily: 'Noto Serif KR', fontWeight: 500, fontSize: IDX_TITLE * lay.idxScale, color: ink, opacity: 0.9, whiteSpace: 'pre', ...pressAt(46 + i)}}>{c.title}</div>
</div>
);
})}
<div style={{position: 'absolute', left: M, top: lay.lineTop}}>
{lay.lines.map((t, i) => (
<div key={i} data-t data-fit={'line' + (i + 1)} style={{width: W - 2 * M, height: LINE_LH * lay.lineScale, overflow: 'hidden'}}>
<div style={{fontFamily: 'Noto Serif KR', fontWeight: 700, fontSize: LINE_SIZE * lay.lineScale, lineHeight: `${LINE_LH * lay.lineScale}px`, color: ink, whiteSpace: 'pre', letterSpacing: '-0.01em', transform: `translateY(${enter(frame, T75(44 + i * 6), T75(28)) * (LINE_LH + 2)}px)`, ...pressAt(8 + i)}}>{t}</div>
</div>
))}
</div>
{foot}{finish}
</AbsoluteFill>
);
}
// 2~4장: 데이터 카드
const T = (x: number) => (x * INTRO) / 42;
const stag = Math.min(4, 19 / Math.max(1, nb - 1));
const grow = (i: number) => ramp(frame, T(3 + i * stag), T(3 + i * stag + 18));
const baseLine = ramp(frame, 0, T(16));
const yearsOp = ramp(frame, T(4), T(18));
const bigT = ramp(frame, T(2), T(36));
const dotR = PITCH * 0.34 * (1 + 0.04 * Math.sin(2 * Math.PI * phase(frame)));
const titleTop = lay.titleTop;
const TS = TITLE_SIZE * lay.tScale, TLH = TITLE_LH * lay.tScale, LS = LABEL * lay.lScale;
// 큰 숫자 세어 올라감: 최종 자릿수만큼 자리, 아직 안 찬 앞자리는 흐리게
const nd = big ? big.body.replace(/\D/g, '').length : 0;
const cur = big ? Math.round(big.value * bigT * 10 ** big.dec) : 0;
const sig = big ? Math.max(String(cur).length, big.dec + 1) : 0;
const ds = String(cur).padStart(nd, '0');
let k = 0;
return (
<AbsoluteFill ref={root} style={{backgroundColor: p.colors.paper, overflow: 'hidden'}}>
{bg}{svgInk}{frameTop}
<svg width={W} height={H} style={{position: 'absolute', inset: 0}}>
<defs>
<pattern id="dots" width={PITCH} height={PITCH} patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<circle cx={PITCH / 2} cy={PITCH / 2} r={dotR} fill={ink} />
</pattern>
</defs>
<g style={{filter: 'url(#printDots)'}}>
{vals.map((v, i) => {
const h = (v.value / maxV) * lay.maxH * grow(i); // 0 기준 선형 비례
return <rect key={i} data-bar data-t x={barX(i)} y={BASE_Y - h} width={barW} height={h} fill={i === hi ? accent : 'url(#dots)'} style={i === hi ? {filter: reg} : undefined} />;
})}
<rect data-t x={CHART_L} y={BASE_Y - 1} width={(CHART_R - CHART_L) * baseLine} height={3} fill={ink} />
</g>
</svg>
{vals.map((v, i) => {
const gr = grow(i);
const h = (v.value / maxV) * lay.maxH * gr;
return (
<div key={i} data-t style={{position: 'absolute', left: barX(i) + barW / 2 - 200, width: 400, top: BASE_Y - h - 14 - LS * 1.1, lineHeight: `${LS * 1.1}px`, textAlign: 'center', fontFamily: MONO, fontSize: LS, color: ink, whiteSpace: 'pre', opacity: i === hi ? ramp(gr, 0.05, 0.3) : ramp(gr, 0.55, 1), ...pressAt(10 + i)}}>
{fmt(i === hi && gr < 1 ? Math.round(v.value * gr) : v.value)}{unit}
</div>
);
})}
{vals.map((v, i) => (
<div key={'l' + i} data-t style={{position: 'absolute', left: barX(i) + barW / 2 - 200, width: 400, top: BASE_Y + 16, lineHeight: `${LS * 1.2}px`, textAlign: 'center', fontFamily: MONO, fontSize: LS - 1, color: ink, whiteSpace: 'pre', opacity: yearsOp, ...pressAt(20 + i)}}>{v.label}</div>
))}
{num && (
<div data-big style={{position: 'absolute', left: M, top: 0, width: num.width, height: H, ...pressAt(7)}}>
{num.row.map((g, i) => {
const isUnit = g.role === 'unit' || (g.role === 'mark' && i < big!.pre.length);
let c = g.c, ghost = false;
if (g.role === 'digit') { const j = k++; c = ds[j]; ghost = j < nd - sig && bigT < 1; }
else if (!isUnit) ghost = k < nd - sig && bigT < 1;
const clip = g.asc + g.desc;
return (
<span key={i} data-t data-glyph={glyphData(g, lay.base)} style={{...glyphStyle(g, 0, lay.base, fix[i] ?? 0), left: centerIn(g, c), overflow: 'hidden', height: clip, color: isUnit ? accent : ink, opacity: ghost ? 0.2 : 1, filter: isUnit ? reg : undefined}}>
<span style={{display: 'block', transform: `translateY(${enter(frame, T(isUnit ? 8 : i * 3), T(16)) * clip * 0.9}px)`}}>{c}<BL /></span>
</span>
);
})}
</div>
)}
{num && auto.tag && (
// 집계 장(6회차): 큰 숫자 오른쪽 작은 '합계'·'평균' — 강조 막대가 없는 이유를 말한다. 잉크 윗끝 = 숫자 잉크 윗끝
<div data-t data-tag style={{position: 'absolute', left: M + num.width + TAG_GAP, top: lay.tagTop, lineHeight: `${TAG_LH}px`, fontFamily: 'Noto Serif KR', fontWeight: 700, fontSize: TAG_SIZE, color: ink, opacity: 0.85 * ramp(frame, T(14), T(30)), whiteSpace: 'pre', letterSpacing: '0.02em', ...pressAt(35)}}>{auto.tag}</div>
)}
<div style={{position: 'absolute', left: M, top: titleTop}}>
{lay.lines.map((t, i) => (
<div key={i} style={{height: TLH, overflow: 'hidden'}}>
<div data-t style={{fontFamily: 'Noto Serif KR', fontWeight: 700, fontSize: TS, lineHeight: `${TLH}px`, color: ink, whiteSpace: 'pre', letterSpacing: '-0.01em', transform: `translateY(${enter(frame, T(6 + i * 4), T(20)) * TLH}px)`, ...pressAt(8 + i)}}>{t}</div>
</div>
))}
</div>
<div data-fit="source" style={{position: 'absolute', left: M, right: M, top: SOURCE_TOP, height: 60, overflow: 'hidden'}}>
<div data-t style={{fontFamily: 'Noto Serif KR', fontWeight: 500, fontSize: 36 * lay.sScale, lineHeight: '60px', color: ink, opacity: 0.85, whiteSpace: 'pre', transform: `translateY(${enter(frame, T(14), T(20)) * 60}px)`, ...pressAt(30)}}>{ch!.source}</div>
</div>
{foot}{finish}
</AbsoluteFill>
);
};
registerRoot(() => <Composition id="Main" component={Report} 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: []`(0.024em 보다 가는 획 없음), `unitBaselineErrPx` ≤ 1.5(단위 아랫선 = 숫자 아랫선), `domAdvanceErrPx` ≤ 1(화면 글자 폭 = 검사한 글자 폭), `domBaselineErrPx` ≤ 1(화면 기준선 = 계산한 기준선, 코드가 재서 보정한 뒤의 남은 오차). 2~4장은 `bars` 의 `px` 와 `expected` 가 모두 같고 `barMaxErrorPx` 가 0.6 이하(막대 비례), `highlight.rule`(`same-value`·`sum`·`mean`·`input`·`max`)·`accentBars` = `expectedAccentBars`(막대 하나 = 1, 합계·평균 장 = 0 이고 `tag` = `합계`·`평균`, `tagBox.gapPx` ≥ 12)·`allSlides`(세 장의 규칙 결과)·`conflict` = false, `unit.used` 가 비어 있지 않음, 1·5장 `numberStyle.filled` = true, 1장 `maxBlankPx` ≤ 300·`index.scale` ≥ 0.7, 5장 `thanks.ratio` ≤ 0.45(감사 잉크 ÷ 숫자 잉크), 2~4장 `chart.maxH`(세 장 같음)·`chart.titleChartGapPx` ≥ 24, 모든 장 `head.errPx` ≤ 1(위 띠 기준선), `specks.after` = 0, `specks540.count` = 0(540 축소본에서 글자 둘레 튀는 점 0, `peak` < `hit`), `texture.cv` ≥ 0.2(종이 결 세기가 영역마다 다름). `overflow` 뜻: `number`(1·5장 숫자가 너무 김, 200px 미만), `number-gap`·`big-gap`(숫자 잉크 간격 부족), `number-thin`·`big-thin`(가는 획), `number-unit-baseline`·`big-unit-baseline`, `number-font`(화면 서체가 검사 서체와 다름)·`number-baseline`(화면 기준선이 1px 넘게 어긋남 — 이 다섯은 코드 문제이니 고치지 말고 보고), `thanks`, `hierarchy`(5장 감사가 숫자에 비해 큼), `cover-blank`(1장 빈 띠 300px 초과)·`index`(1장 차례 제목이 길어 70% 미만으로 줄어듦 — 2~4장 제목을 줄여 달라고), `line1`·`line2`·`line`·`line-lines`(한 줄 넘침), `brand+info`(위 띠 겹침), `title`·`title-lines`·`title-chart`(제목이 길어 차트와 닿음), `labels`, `source`, `big`, `bar-ratio`, `highlight-rule`(입력 * 가 강조 규칙과 어긋남 — `warn` 의 제안을 사용자에게 묻는다, 마음대로 고치지 않는다), `unit-missing`(막대 값 단위를 알 수 없음 — 사용자에게 단위를 묻는다), `brand-2to4`(2~4장에 브랜드명). 다음은 코드 문제이니 고치지 말고 보고: `head-y`(위 띠 기준선 어긋남), `highlight-count`(포인트색 막대 수가 규칙과 다름), `tag`(`합계`·`평균` 표기가 숫자와 닿거나 여백을 넘음), `plate-specks`·`specks-540`(글자 둘레 점이 남음), `texture-uniform`(종이 결이 고른 필터처럼 균일).
2. `out/s1.png` ~ `out/s5.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구). 확인: 숫자를 피드 크기(가로 360px)로 줄여 봐도 4 가 1 로, 쉼표가 마침표로 읽히지 않는지, 숫자끼리 붙거나 겹치지 않는지, 단위가 숫자 아랫선에 앉았는지, 1·5장 가운데가 비어 보이지 않는지, 5장은 숫자 하나만 크고 감사는 작은 머리말인지, 1장 차례 세 줄이 2~4장 글자와 같은지, 숫자·쉼표 자리·단위·제목·막대 숫자·라벨·출처·감사·한 줄·브랜드·작은 정보가 입력과 글자 하나까지 같은지, 막대 개수·순서·강조 막대 색(큰 숫자와 같은 값의 막대 하나 / 합계·평균 장은 강조 없이 큰 숫자 옆 `합계`·`평균` / 그 외는 가장 큰 막대 또는 * 막대), 막대 숫자에 단위가 붙었는지, 5장 숫자가 1장과 같은 칠한 세리프인지, 1·5장과 2~4장의 위 띠 글자 높이가 같은지, 글자 둘레(피드 크기 540px 로 줄여서도)에 문장부호처럼 보이는 섬유·점이 없는지, 막대 높이 차이가 값 차이와 맞아 보이는지, 2~4장에 브랜드명이 없는지, 다섯 장의 종이색·서체·여백이 같은지, 종이에 원래 있던 글자·선·자국이 없는지.
3. MP4 이면 `out/frames/` 의 장별 4장(0·0.7·1.4·5.36초)을 본다: 0초는 빈 종이, 0.7초는 숫자가 세어 올라가고 막대가 자라는 중, 1.4초부터 완성(값 그대로), 끝 프레임도 완성. 영상마다 5.4초, 1080×1350, 30fps.
4. 문제가 있으면 원인(판·글자 수·props)을 고쳐 다시 렌더한다. 판에 글자·선이 남았으면 판을 다시 만든다.
## 8. 보고
결과 파일 경로(`out/s1.png`~`out/s5.png`, MP4 면 `out/s1.mp4`~`out/s5.mp4`), 배경판을 어떻게 얻었는지(생성·내 사진·예시 판·ChatGPT)와 check.json 의 `plateMode`, 막대 비례 결과(`bars`), 장별 강조(`highlight`), `head`, `specks540`, `texture` 와 `warn`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 인스타그램 캐러셀에는 PNG 5장을 순서대로 올린다. MP4 는 장마다 등장 1.4초 + 분위기 4초(측광 왕복)다.
폰트(전부 SIL OFL 1.1, 상업 사용 가능): 숫자 Source Serif 4(Black 900 — 굵은 디스플레이 세리프, 가는 획도 0.024em 이상, 모든 숫자가 기준선에 앉는다), 브랜드·제목·감사·단위·한 줄·출처 Noto Serif KR, 작은 정보·막대 숫자·라벨·쪽 번호 IBM Plex Mono(한글 라벨은 Noto Serif KR).
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Claude Code·Codex 같은 코딩 에이전트에 붙여 넣고 브랜드·색·주제·1장~5장 글자를 알려 주세요. 실제 폰트로 5장을 렌더해 글자가 깨지지 않아요.
- 올리기
PNG 5장(원하면 장마다 MP4)을 받아 인스타그램에서 여러 장 선택으로 순서대로 올리세요.
글자 분량
- 1장 · 표지
- 숫자 7자 · 한 줄 16자
- 2~4장 · 데이터
- 큰 숫자 8자 · 제목 16자 · 출처 14자
- 5장 · 마지막 장
- 감사 6자 · 숫자 7자 · 한 줄 16자
한도를 넘기면 ChatGPT가 그리지 않고 줄여 달라고 되묻습니다.
지킬 것
- 잘 맞는 글: 한 해 결산, 누적 기록, 이용 통계
- 숫자는 직접 센 값만 넣으세요. 막대 높이는 값에 정확히 비례하고, 큰 숫자가 합계면 막대를 강조하지 않고 '합계'라고 적습니다.
- 코딩 에이전트(Claude Code·Codex)가 실제 폰트로 렌더해 글자가 깨지지 않습니다. 브랜드명은 1장과 5장에만 나옵니다.