← 캐러셀
캐러셀 · 타이포
채용 공고
코드 렌더 · 같은 결과
뽑는 직무를 표지에 크게 찍고, 업무·조건·혜택·지원을 한 장씩 알려 주세요.
계기채용
1장1 / 5
가상 브랜드 볕 드는 카페로 레시피를 실제 실행해 렌더한 5장입니다. 번호나 화살표로 넘겨 보세요.
레시피 보기144,113자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 채용 캐러셀 5장 (사진 없이 타이포 — 직무명 초대형)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델에게 글자도, 사람도 그리게 하지 않는다. 글자 없는 종이 판 한 장(장마다 다른 자리로 잘라 장의 바탕색으로 물들인다) 위에 실제 폰트로 글자를 코드로 얹고, 헤드리스 브라우저로 5장(PNG, 필요하면 장별 MP4)을 렌더한다. 다섯 장은 한 컴포지션이라 서체·여백·괘선·색이 장마다 같다. 생성 사진을 쓰지 않는다(이전 채용 캐러셀은 생성 사진의 질감·연출 티로 막혔다).
- 1장(표지): 포인트색 바탕 + **직무명 초대형**(Hahmlet 900, 폭에 꽉 차게 — 띄어쓰기가 있으면 낱말마다 한 줄, **띄어쓰기 없는 한 낱말은 쌓지 않고 한 줄**(넘치면 글자 크기를 줄인다 — 음절을 쌓으면 띄어쓰기로 읽힌다)) + 한 줄(크게, 길면 두 줄) + 맨 아래 차례 줄(02 업무 · 03 조건 · 04 복지 · 05 지원 — 2~5장 큰 낱말을 그대로 다시 쓴다)
- (선택) 사용자가 준 **작업장 사진 1장을 1장 바탕으로만** 쓴다: 흑백으로 바꿔 톤을 편 뒤 포인트색으로 곱해 단색 인쇄처럼, 위·아래를 어둡게 하고 글자는 종이색. 2~5장에는 사진을 쓰지 않는다
- 2~4장: 종이 바탕 + 같은 크기 큰 낱말(업무·조건·복지 등, 포인트색) + 번호 붙은 줄 1~4개(괘선 사이. **목록 시작 높이는 2~5장 공통**이고, 목록은 아래 여백까지 칸을 채운다 — 칸 높이 = 남는 높이 / 줄 수, 글자 크기 = 줄 수별 상한과 폭. 2~5장 같은 규칙이라 항목이 2개인 장도 아래 1/3 이 비지 않는다) — **브랜드 금지**, 위 띠는 직무명
- 5장: 색면 뒤집기(바탕 = 글자색) + 같은 크기 큰 낱말(지원 등) + 지원 방법 줄 1~3개(줄이 적을수록 조금 크게) + 맨 아래 브랜드 맺음 줄
- 색 대비 하한(WCAG): 줄·번호·한 줄 ≥ 4.5, 큰 낱말 ≥ 3(5장 뒤집힌 바탕 위는 ≥ 5.5). **색 역할(밝기 기준)**: 대비를 맞추려고 밝기를 조금(어둡게 0.08·밝게 0.18 이내, OKLCH) 옮겨서 되면 그 색, 더 많이 옮겨야 하는 밝은 포인트색(노랑 등)은 **글자색으로 쓰지 않는다**(어둡게 누르면 올리브·겨자로 탁해진다) — 2~5장 큰 낱말을 포인트색 블록(면) 위에 글자색으로 찍고 번호도 글자색. 1장 바탕·2~5장 블록이 같은 포인트색이라 다섯 장의 브랜드 색 인상이 같다(코드가 `accent-drift` 로 검사). 이메일처럼 긴 줄은 오른쪽 여백 36px 안으로 들어오게 글자 크기를 줄인다
- 쪽 표시는 숫자 대신 막대 5개(지금 장만 진하게) — 줄 번호 01·02 와 숫자 체계가 겹치지 않는다
- **2~5장 변주**: 큰 낱말(과 포인트색 블록)을 짝수 장(2·4)은 왼쪽, 홀수 장(3·5)은 오른쪽에 붙인다 — 같은 서체·크기·기준선, 목록은 같은 그리드. 블록은 붙은 쪽 화면 끝까지 나간다(같은 틀 반복의 단조로움을 규칙적으로 깬다)
- 큰 글자·블록은 **깨끗한 단색 잉크** — 아주 낮은 주파수 압력 차이와 가장자리 바깥 번짐뿐, 줄무늬·빈 점 없음(종이 결·얼룩·그레인은 잉크 아래 종이에만). 렌더 뒤 `render.mjs` 가 장마다 완성 PNG 의 잉크 면 안쪽을 잰다(`ink-texture`). 1장 제목이 바탕보다 밝으면 농도 판 없이 가장자리만. 작은 글자는 인쇄 단서(가장자리 거칠기·눌림), 종이 결은 영역마다 세기가 다르고 판 전체의 점·얼룩은 지운다
계기: 채용(구인·모집). 4회차: 3회차 검수의 노랑 블록 위 점 줄·큰 글자 안 세로 줄무늬(점·얼룩 관문 실패)와 2~4장 단조로움을 고쳤다.
원본 템플릿: https://duchamp.app/readymade/carousels/hiring-type-carousel (바탕이 된 이미지 템플릿: https://duchamp.app/readymade/images/poster-word-over-photo)
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "종이색, 글자색, 포인트색" HEX 3개. 비우면 #F1EBDF, #1E1C1A, #F0592A
# 포인트색 = 1장 바탕·2~5장 큰 낱말(밝은 노랑 등은 큰 낱말 뒤 블록 면으로 — 코드가 정함), 글자색 = 5장 바탕
주제: (내 브랜드에 맞는 주제 하나) # 어떤 일을 뽑는지. 예: 동네 빵집 새벽 제빵사 채용
넣을 글자: (주제에 맞춰 분량 상한 안에서 장별 문구를 지어 넣는다) # 장마다 한 줄씩, "큰 글자 / 줄 / 줄 …"
# 1장: 직무명 / 한 줄 직무명 10자 이내(띄어쓰기 = 줄바꿈, 낱말 3개까지. 띄어쓰기 없는 낱말은 한 줄), 한 줄 18자 이내
# 2장: 큰 낱말 / 줄 1~4개(업무) 큰 낱말 4자 이내(예: 업무·하는 일), 줄 각 18자 이내
# 3장: 큰 낱말 / 줄 1~4개(조건)
# 4장: 큰 낱말 / 줄 1~4개(복지)
# 5장: 큰 낱말 / 줄 1~3개(지원 방법: 연락처·마감 등, 연락처 줄은 32자 이내 — 길면 코드가 글자를 줄인다)
# 2~4장 줄 수를 2~4개로 맞추면 가장 고르다. 2~4장에는 브랜드명을 넣지 않는다
출력: PNG만 # PNG만 | MP4까지
배경판: 자동 # 자동 | 내 사진: <경로> | 예시 판 | ChatGPT 판: <경로> (글자 없는 무코팅 종이 한 장)
1장 사진: 없음 # 없음 | <경로> (선택: 실제 작업장 사진 1장 — 1장 바탕으로만 쓴다)
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 글자 한도·줄 수를 넘으면 줄여 달라고 한다. 브랜드명·넣을 글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 채용 인원·급여·조건·혜택·연락처 등 주지 않은 사실은 만들지 않는다. 번호(01·02…)·차례 줄 번호(02~05)·쪽 표시(막대 5개)는 코드가 붙인다.
- `1장 사진` 은 사용자가 준 파일만 쓴다. 사진을 생성하거나 다른 데서 가져오지 않는다. 사진이 없으면 사진 없이 만든다(그래도 완성된 디자인이다).
- **2~4장에는 브랜드명을 쓰지 않는다.** 2~4장 글자에 브랜드명이 들어 있으면(띄어쓰기 무시) 코드 검사가 `brand-2to4` 로 실패한다. 그때는 뺀 안을 사용자에게 물어본다. 브랜드는 1장과 5장에만 나온다.
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.json` 하나뿐이다. 레이아웃·크기·줄바꿈·장별 판 자르기는 코드가 재서 정한다. 코드가 실패하면 오류 메시지를 그대로 보고하고, 고쳐야 했다면 무엇을 왜 고쳤는지 보고에 쓴다.
- 비밀 값·.env 파일은 읽지 않는다.
- 필요한 것: Node 18 이상, 인터넷(`npm install` 때만 — 폰트도 npm 패키지로 받으므로 렌더는 인터넷 없이 된다). 설치는 이 폴더 안에서만(`npm install` — 렌더러 HyperFrames(Apache 2.0)·esbuild·React·ffmpeg). 첫 실행 때 HyperFrames 가 헤드리스 Chrome(약 190MB)을 이 폴더의 `.render/home` 에 받는다. ffmpeg 은 npm 패키지(ffmpeg-static)로 따라오므로 따로 설치하지 않는다(그 설치가 막힌 환경이면 시스템 ffmpeg 를 쓴다).
- 이 작업 밖의 일(프로젝트 관리 카드·메모 갱신, 배포, 다른 브라우저 자동화 도구, 사용자에게 묻지 않은 유료 생성 API·크레딧 사용)은 하지 않는다.
- **샌드박스 안에서 실행 중이면(Codex 기본 모드 등)**: `npm install` 은 네트워크가, `node render.mjs` 는 헤드리스 Chrome 실행이 필요하다. macOS 샌드박스에서는 Chrome 이 `bootstrap_check_in ... Permission denied (1100)` / `Failed to launch the browser process` 로 막힌다. 이 오류가 나면 우회하지 말고 바로 사용자에게 "샌드박스 밖 실행(권한 상승) 승인"을 요청해 같은 명령을 다시 실행한다. 승인을 받을 수 없으면 거기서 멈추고, 사용자가 일반 터미널에서 실행할 명령 한 줄(`cd <폴더> && node render.mjs mp4`)을 보고한다.
- `npm install` 이 `EPERM`(홈의 npm 캐시 권한)으로 실패하면 `npm install --no-audit --no-fund --cache .npm-cache` 로 이 폴더 안 캐시를 쓴다.
## 2. 파일 구조 (현재 폴더에 만든다)
```
package.json render.mjs hf.mjs fonts.json props.json src/index.tsx src/duchamp-frame.tsx
public/plates/plate.(png|jpg) ← 글자·선 없는 무코팅 종이 판 한 장(다섯 장이 같이 쓴다)
public/plates/photo.(jpg|png) ← 1장 사진(있을 때만)
out/s1.png … out/s5.png out/check.json (out/s1.mp4 … out/s5.mp4 out/frames/*.png)
```
판은 장 수만큼 만들지 않는다. 한 장을 코드가 장마다 조금씩 다른 자리로 잘라 그 장의 바탕색(1장 포인트색, 2~4장 종이색, 5장 글자색)으로 물들인다.
## 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/hiring-type-carousel/plate.jpg -o public/plates/plate.jpg
```
판 생성 프롬프트(이미지 도구에 그대로; `{종이색}` 만 HEX 로 바꿔 넣는다):
```
A sheet of thick uncoated cotton card stock in colour {종이색}, photographed straight on and full-bleed, portrait 4:5. The paper fills the entire frame edge to edge — no paper edges, no table, no shadows of objects. Visible cotton fibres and an even fine tooth, clean surface with NO specks, dots, dust, hairs or inclusions, NO streaks, scan lines, banding or stripes of any direction, soft low raking light from the left, gentle vignette. Completely blank: absolutely NO text, letters, numbers, lines, rules, shapes, deboss marks, logos or watermarks. Fully opaque.
```
**1장 사진**(`1장 사진: <경로>`): 그 파일을 `public/plates/photo.<확장자>` 로 복사하고 props 의 `photo` 에 경로를 넣는다. 컬러여도 코드가 흑백 → 포인트색 단색으로 바꾼다. 사진 속 글자·간판·사람 얼굴은 코드가 지우지 않으니, 직접 열어 보고 문제가 있으면(예: 다른 가게 간판, 동의 없는 얼굴) 사용자에게 알린다. 결과물은 사용자의 실제 사진을 쓴 연출 이미지다.
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"title": "<1장 직무명 그대로>",
"line": "<1장 한 줄 그대로>",
"sections": [
{"word": "<2장 큰 낱말>", "lines": ["<줄>", "..."]},
{"word": "<3장 큰 낱말>", "lines": ["..."]},
{"word": "<4장 큰 낱말>", "lines": ["..."]}
],
"last": {"word": "<5장 큰 낱말>", "lines": ["<지원 방법 줄>", "..."]},
"colors": {"paper": "<종이색>", "ink": "<글자색>", "accent": "<포인트색>"},
"plate": "plates/plate.<확장자>",
"photo": null
}
```
- 1장 직무명은 폭에 꽉 차게(최대 470px), 한 줄은 최대 112px(두 줄이 1.15배 이상 크면 두 줄, 꾸미는 말 뒤에서는 끊지 않는다). 2~5장 큰 낱말은 네 장 공통 크기(가장 긴 낱말 기준, 최대 360px), 2~5장 줄은 줄 수에 따라 최대 110·92·78·64px(같은 규칙, 폭에 맞춰 줄임)이고 칸이 목록 자리를 채운다. 너무 작아지면 검사 실패로 알린다.
- 사진이 있으면 `"photo": "plates/photo.jpg"`(없으면 null).
## 5. 코드 (그대로 저장)
`package.json`
```json
{
"name": "duchamp-recipe",
"private": true,
"type": "module",
"dependencies": {
"hyperframes": "0.8.137",
"esbuild": "0.25.12",
"react": "19.1.0",
"react-dom": "19.1.0",
"ffmpeg-static": "5.3.0",
"@ffprobe-installer/ffprobe": "2.1.2",
"@fontsource-variable/hahmlet": "5.3.0",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans-kr": "5.3.0",
"@fontsource-variable/noto-sans-kr": "5.3.0"
}
}
```
`render.mjs`
```js
// usage: node render.mjs png | mp4 [장 번호들, 예: 1,3] → out/s1.png … out/s5.png (+ out/s1.mp4 …), out/check.json
// 4회차: 장마다 PNG 를 만든 뒤 잉크 면 검사(inkTexture) — 같은 장을 inkMask 로 한 번 더 그려 out/s<n>-inkmask.png, 완성 PNG 의 잉크 면 안쪽을 잰다
import fs from 'node:fs';
import path from 'node:path';
import {spawnSync} from 'node:child_process';
import zlib from 'node:zlib';
import {ff, meta, pick, render} from './hf.mjs';
// ── 잉크 면 검사(4회차): 완성 PNG(file) 와 잉크 면 마스크 PNG(maskFile, 면마다 다른 순색·나머지 검정)를
// (PNG 는 node 내장 zlib 로 직접 푼다) 면 안쪽(가장자리 ER px 안쪽 — 같은 순색만 있는 자리)에서 밝기(0~1)의 ① 고주파 세기(9×9 평균과의 차의 평균) ② 세로·가로 줄(열·행 평균의 흔들림 RMS)과
// 줄 주기성(열 평균의 자기상관 최댓값, 3~60px) ③ 고립 점(평균과 0.05 넘게 튀는 덩어리 수)을 잰다. 1080 원본과 540 축소본(2×2 평균) 모두.
// 상한: 고주파 0.004 · 줄 RMS 0.002(주기성 0.4 넘으면 0.001) · 점 0 — 넘으면 ok=false(overflow ink-texture)
const INK_LIMIT = {hf: 0.004, stripe: 0.002, stripePeriodic: 0.001, acMax: 0.4, dotThr: 0.05, dots: 0};
// PNG 풀기(node 내장 zlib — 8비트 RGB/RGBA/회색, 인터레이스 없음 = HyperFrames 출력). 결과 = RGB 바이트 w×h×3
function rawRGB(file, w, h) {
const b = fs.readFileSync(file);
let o = 8, iw = 0, ih = 0, depth = 0, ct = 0, il = 0; const idat = [];
while (o < b.length) {
const len = b.readUInt32BE(o), type = b.toString('latin1', o + 4, o + 8), d = b.subarray(o + 8, o + 8 + len);
if (type === 'IHDR') { iw = d.readUInt32BE(0); ih = d.readUInt32BE(4); depth = d[8]; ct = d[9]; il = d[12]; }
else if (type === 'IDAT') idat.push(d); else if (type === 'IEND') break;
o += 12 + len;
}
const ch = {0: 1, 2: 3, 4: 2, 6: 4}[ct];
if (iw !== w || ih !== h || depth !== 8 || !ch || il) throw new Error(`PNG 형식이 예상과 다름: ${file} ${iw}×${ih} depth ${depth} type ${ct} interlace ${il}`);
const raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * ch, px = Buffer.alloc(stride * h);
for (let y = 0; y < h; y++) {
const f = raw[y * (stride + 1)], src = y * (stride + 1) + 1, dst = y * stride;
for (let x = 0; x < stride; x++) {
const a = x >= ch ? px[dst + x - ch] : 0, up = y ? px[dst - stride + x] : 0, ul = y && x >= ch ? px[dst - stride + x - ch] : 0;
let v = raw[src + x];
if (f === 1) v += a; else if (f === 2) v += up; else if (f === 3) v += (a + up) >> 1;
else if (f === 4) { const p = a + up - ul, pa = Math.abs(p - a), pb = Math.abs(p - up), pc = Math.abs(p - ul); v += pa <= pb && pa <= pc ? a : pb <= pc ? up : ul; }
px[dst + x] = v & 255;
}
}
const out = Buffer.alloc(w * h * 3);
for (let p = 0; p < w * h; p++) for (let k = 0; k < 3; k++) out[p * 3 + k] = px[p * ch + (ch < 3 ? 0 : k)];
return out;
}
function inkTexture(file, maskFile, zones, w, h) {
if (!zones || !Object.keys(zones).length) return {ok: true, zones: {}, note: '잉크 면 없음'};
const img = rawRGB(file, w, h), msk = rawRGB(maskFile, w, h);
const names = Object.entries(zones).map(([hex, n]) => ({n, c: [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16))}));
const Y = new Float32Array(w * h), id = new Int16Array(w * h).fill(-1);
for (let p = 0; p < w * h; p++) {
const k = p * 3;
Y[p] = (0.2126 * img[k] + 0.7152 * img[k + 1] + 0.0722 * img[k + 2]) / 255;
for (let z = 0; z < names.length; z++) { const c = names[z].c; if (Math.abs(msk[k] - c[0]) <= 2 && Math.abs(msk[k + 1] - c[1]) <= 2 && Math.abs(msk[k + 2] - c[2]) <= 2) { id[p] = z; break; } }
}
const measureAt = (Y, id, w, h, ER, BR) => {
// 안쪽: 가로·세로 ER 안이 모두 같은 면(줄 단위 연속 구간으로 계산)
const hs = new Int16Array(w * h).fill(-1), inn = new Int16Array(w * h).fill(-1);
for (let y = 0; y < h; y++) { let x = 0; while (x < w) { const v = id[y * w + x]; let e = x; while (e + 1 < w && id[y * w + e + 1] === v) e++; if (v >= 0) for (let q = x + ER; q <= e - ER; q++) hs[y * w + q] = v; x = e + 1; } }
for (let x = 0; x < w; x++) { let y = 0; while (y < h) { const v = hs[y * w + x]; let e = y; while (e + 1 < h && hs[(e + 1) * w + x] === v) e++; if (v >= 0) for (let q = y + ER; q <= e - ER; q++) inn[q * w + x] = v; y = e + 1; } }
// 9×9(축소본 7×7) 평균 — 적분 영상
const I = new Float64Array((w + 1) * (h + 1));
for (let y = 0; y < h; y++) { let row = 0; for (let x = 0; x < w; x++) { row += Y[y * w + x]; I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row; } }
const mean = (x, y) => { const x0 = Math.max(0, x - BR), y0 = Math.max(0, y - BR), x1 = Math.min(w, x + BR + 1), y1 = Math.min(h, y + BR + 1); return (I[y1 * (w + 1) + x1] - I[y0 * (w + 1) + x1] - I[y1 * (w + 1) + x0] + I[y0 * (w + 1) + x0]) / ((x1 - x0) * (y1 - y0)); };
const out = {};
for (let z = 0; z < names.length; z++) {
let n = 0, hf = 0; const col = new Float64Array(w), cn = new Float64Array(w), row = new Float64Array(h), rn = new Float64Array(h), hot = new Uint8Array(w * h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const p = y * w + x; if (inn[p] !== z) continue; const d = Y[p] - mean(x, y); n++; hf += Math.abs(d); col[x] += d; cn[x]++; row[y] += d; rn[y]++; if (Math.abs(d) > INK_LIMIT.dotThr) hot[p] = 1; }
if (n < 400) continue;
const prof = (v, c, minN) => { const o = []; for (let i = 0; i < v.length; i++) o.push(c[i] >= minN ? v[i] / c[i] : null); return o; };
const rms = (a) => { const b = a.filter((v) => v !== null); return b.length ? Math.sqrt(b.reduce((s, v) => s + v * v, 0) / b.length) : 0; };
const cp = prof(col, cn, 40), rp = prof(row, rn, 40);
// 세로 줄 주기성: 열 평균의 자기상관(정규화) 최댓값, 3~60px
let ac = 0; { const b = cp.map((v) => v ?? 0), e = b.reduce((s, v) => s + v * v, 0); if (e > 0) for (let lag = 3; lag <= 60; lag++) { let s = 0; for (let i = 0; i + lag < b.length; i++) s += b[i] * b[i + lag]; ac = Math.max(ac, s / e); } }
// 고립 점: 튀는 픽셀의 4-이웃 덩어리 수
let dots = 0; const at = [];
for (let p = 0; p < w * h; p++) { if (!hot[p]) continue; dots++; const st = [p]; hot[p] = 0; let sx = 0, sy = 0, m = 0; while (st.length) { const j = st.pop(); m++; sx += j % w; sy += (j / w) | 0; for (const q of [j - 1, j + 1, j - w, j + w]) if (q >= 0 && q < w * h && hot[q]) { hot[q] = 0; st.push(q); } } if (at.length < 6) at.push([Math.round(sx / m), Math.round(sy / m), m]); }
out[names[z].n] = {px: n, hf: +(hf / n).toFixed(4), stripeX: +rms(cp).toFixed(4), stripeY: +rms(rp).toFixed(4), periodic: +ac.toFixed(2), dots, ...(at.length ? {dotsAt: at} : {})};
}
return out;
};
const full = measureAt(Y, id, w, h, 5, 4);
// 540 축소본: 2×2 평균, 마스크는 네 픽셀이 같은 면일 때만
const w2 = w >> 1, h2 = h >> 1, Y2 = new Float32Array(w2 * h2), id2 = new Int16Array(w2 * h2).fill(-1);
for (let y = 0; y < h2; y++) for (let x = 0; x < w2; x++) {
const a = (2 * y) * w + 2 * x, q = [a, a + 1, a + w, a + w + 1];
Y2[y * w2 + x] = (Y[q[0]] + Y[q[1]] + Y[q[2]] + Y[q[3]]) / 4;
if (q.every((k) => id[k] === id[a])) id2[y * w2 + x] = id[a];
}
const half = measureAt(Y2, id2, w2, h2, 3, 3);
const fail = [];
for (const [tag, r] of [['1080', full], ['540', half]]) for (const [n, z] of Object.entries(r)) {
if (z.hf > INK_LIMIT.hf) fail.push(`${tag} ${n} 고주파 ${z.hf} > ${INK_LIMIT.hf}`);
const lim = z.periodic > INK_LIMIT.acMax ? INK_LIMIT.stripePeriodic : INK_LIMIT.stripe;
if (z.stripeX > lim) fail.push(`${tag} ${n} 세로 줄 ${z.stripeX} > ${lim}${z.periodic > INK_LIMIT.acMax ? '(주기성 ' + z.periodic + ')' : ''}`);
if (z.stripeY > INK_LIMIT.stripe) fail.push(`${tag} ${n} 가로 줄 ${z.stripeY} > ${INK_LIMIT.stripe}`);
if (z.dots > INK_LIMIT.dots) fail.push(`${tag} ${n} 고립 점 ${z.dots}개`);
}
return {ok: fail.length === 0, limit: INK_LIMIT, fail, at1080: full, at540: half};
}
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 없음'};
{
const k = await render({name: `s${n}-inkmask`, props: {...inputProps, inkMask: true}, m: metas[n], format: 'png', frames: [42], out: [`out/s${n}-inkmask.png`]});
if (k.fail) { console.error(`렌더 실패(${n}장 inkMask):`, k.fail); process.exit(1); }
const zones = pick(k.logs, 'DUCHAMP_INKZONES').at(-1) ?? null;
const it = inkTexture(`out/s${n}.png`, `out/s${n}-inkmask.png`, zones, metas[n].width, metas[n].height);
c.inkTexture = it;
if (!it.ok) { c.ok = false; c.overflow = [...(c.overflow ?? []), 'ink-texture']; c.hint = (c.hint ? c.hint + ' / ' : '') + 'ink-texture(잉크 면 안쪽 고주파·줄·점) 는 코드 문제다 → 고치지 말고 check.json 의 inkTexture 를 그대로 보고'; }
}
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};
};
// ── 9차 공통(batch4): 색 대비·겹친 색 · 글리프 누락 · 글자 상자 겹침 · 한 줄 맞춤 · (12차) 인쇄 농도 판·색 역할 ──
// 색: 상대 휘도·대비(WCAG), 곱하기(리소 두 판이 겹친 색 = 잉크 × 잉크 × 종이)
const lumHex = (h: string) => { const c = hexRgb(h).map((v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
const contrastHex = (a: string, b: string) => { const [x, y] = [lumHex(a), lumHex(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
const toHex = (c: number[]) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
const mulHex = (...hs: string[]) => toHex(hs.map(hexRgb).reduce((a, b) => a.map((v, k) => (v * b[k]) / 255)));
const mixHex = (a: string, b: string, t: number) => { const x = hexRgb(a), y = hexRgb(b); return toHex(x.map((v, k) => v + (y[k] - v) * t)); };
// 두 색이 얼마나 다른가(0~1, sRGB 거리 / √3)
const colorDist = (a: string, b: string) => { const x = hexRgb(a), y = hexRgb(b); return Math.sqrt(x.reduce((s, v, k) => s + (v - y[k]) ** 2, 0)) / (255 * Math.sqrt(3)); };
// 글리프 누락 검사: 그 서체로 그린 글자와 대체 글꼴(monospace)만으로 그린 글자가 픽셀까지 같으면 그 서체에 글자가 없다(대체 글꼴로 조용히 그려짐)
const glyphMissing = (family: string, weight: number, text: string) => {
const S = 64, c = document.createElement('canvas'); c.width = S; c.height = S;
const g = c.getContext('2d', {willReadFrequently: true})!;
const shot = (font: string, ch: string) => { g.clearRect(0, 0, S, S); g.font = font; g.fillStyle = '#000'; g.textBaseline = 'alphabetic'; g.fillText(ch, 4, 50); return g.getImageData(0, 0, S, S).data; };
const miss: string[] = [];
for (const ch of new Set([...text])) {
if (/\s/.test(ch)) continue;
const a = shot(`${weight} 44px "${family}", monospace`, ch), b = shot(`${weight} 44px monospace`, ch);
let same = true;
for (let i = 3; i < a.length; i += 4) if (a[i] !== b[i]) { same = false; break; }
if (same) miss.push(ch);
}
return miss;
};
// 글자 상자 겹침: 두 상자가 gap px 안으로 다가오면 겹침으로 본다
const rectGap = (a: Rect, b: Rect) => Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w), b.y - (a.y + a.h), a.y - (b.y + b.h));
const overlaps = (rs: (Rect & {name: string})[], gap: number) => {
const out: string[] = [];
rs.forEach((a, i) => rs.slice(i + 1).forEach((b) => { if (rectGap(a, b) < gap) out.push(a.name + '×' + b.name + ' ' + Math.round(rectGap(a, b)) + 'px'); }));
return out;
};
const inside = (r: Rect, x0: number, y0: number, x1: number, y1: number) => r.x >= x0 - 0.5 && r.y >= y0 - 0.5 && r.x + r.w <= x1 + 0.5 && r.y + r.h <= y1 + 0.5;
// 한 줄 맞춤: 폭 maxW 안에 들어가는 가장 큰 크기(최대 cap). font = (size) => CSS font 문자열, track = 자간(em)
const fitLine = (t: string, font: (s: number) => string, cap: number, maxW: number, track = 0) => {
const g = document.createElement('canvas').getContext('2d')!; g.font = font(100);
const w100 = g.measureText(t).width + track * 100 * Math.max(0, [...t].length - 1);
return Math.min(cap, (maxW / Math.max(1, w100)) * 100);
};
const measure = (t: string, font: string, track = 0, size = 0) => { const g = document.createElement('canvas').getContext('2d')!; g.font = font; return g.measureText(t).width + track * size * Math.max(0, [...t].length - 1); };
// 잉크 상자(캔버스 actualBoundingBox): 기준선 위 asc · 아래 desc · 왼쪽 l · 오른쪽 r (원점 = 기준선 왼쪽 끝)
const inkOf = (t: string, font: string) => { const g = document.createElement('canvas').getContext('2d')!; g.font = font; const m = g.measureText(t); return {asc: m.actualBoundingBoxAscent, desc: m.actualBoundingBoxDescent, l: -m.actualBoundingBoxLeft, r: m.actualBoundingBoxRight, adv: m.width, fa: m.fontBoundingBoxAscent, fd: m.fontBoundingBoxDescent}; };
// 줄 상자 top 을 구한다: 잉크 윗끝이 inkTop 에 오도록(줄 높이 lh, CSS 는 글꼴 상자를 줄 가운데에 놓는다)
const topForInk = (t: string, font: string, lh: number, inkTop: number) => { const k = inkOf(t, font); return inkTop - ((lh - (k.fa + k.fd)) / 2 + k.fa - k.asc); };
const baseOf = (t: string, font: string, lh: number, top: number) => { const k = inkOf(t, font); return top + (lh - (k.fa + k.fd)) / 2 + k.fa; };
// ── 10차(batch4 2회차) 색 보정: OKLCH 로 밝기만 옮겨 같은 색 계열을 유지한다(색상각 그대로, 채도는 화면 색 범위 안으로만 줄임) ──
const srgbLin = (v: number) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
const linSrgb = (v: number) => 255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055);
const hexOklch = (h: string) => {
const [r, g, b] = hexRgb(h).map(srgbLin);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b), m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b), s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return {L, C: Math.hypot(A, B), h: Math.atan2(B, A)};
};
const oklchRgb = (L: number, C: number, h: number) => {
const A = C * Math.cos(h), B = C * Math.sin(h);
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3, m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3, s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
};
const oklchHex = (L: number, C: number, h: number) => {
let c = C;
for (let k = 0; k < 40; k++) { const v = oklchRgb(L, c, h); if (v.every((x) => x >= -0.0005 && x <= 1.0005)) break; c *= 0.93; }
return toHex(oklchRgb(L, c, h).map((x) => linSrgb(Math.max(0, Math.min(1, x)))));
};
const chromaOf = (h: string) => hexOklch(h).C;
// shownOf(c) = 그 색이 화면에 보이는 색(리소면 잉크 × 종이). bg 와 대비가 target 이상이 될 때까지 밝기만 옮긴다(bg 가 밝으면 어둡게, 어두우면 밝게)
const inkFor = (c: string, bg: string, target: number, shownOf: (x: string) => string = (x) => x) => {
if (contrastHex(shownOf(c), bg) >= target) return c;
const o = hexOklch(c), dark = lumHex(bg) > 0.18;
for (let k = 1; k <= 100; k++) {
const L = dark ? o.L * (1 - k / 100) : o.L + (1 - o.L) * (k / 100);
const x = oklchHex(L, o.C, o.h);
if (contrastHex(shownOf(x), bg) >= target) return x;
}
return dark ? '#000000' : '#FFFFFF';
};
// ── 12차(batch4 3회차) 색 역할 규칙(밝기 기준) ──
// 밝은 브랜드 색(노랑 등)을 글자에 쓰려고 어둡게 누르면 올리브·겨자로 탁해져 같은 브랜드 색으로 읽히지 않는다(2회차 검수 두 건 공통 지적).
// 규칙: 바탕 대비가 모자랄 때 밝기를 옮겨도 되는 폭은 어둡게 SHIFT_DOWN(0.08)·밝게 SHIFT_UP(0.18)(OKLCH L). 그보다 많이 옮겨야 하면 그 색은 '면' 전용 —
// 면·띠·하이라이트로만 쓰고, 글자는 진한 중성색(먹색) 또는 그 색의 보색 계열 중 바탕 대비가 target 이상인 쪽으로 찍는다.
const SHIFT_DOWN = 0.08, SHIFT_UP = 0.18;
// 먹색: 그 색상각을 아주 조금(채도 0.012) 띤 진한 중성색 — 순검정보다 종이 위 잉크처럼 보인다
const mukOf = (hue: string, L = 0.24) => oklchHex(L, 0.012, hexOklch(hue).h);
type Role = {role: 'text' | 'text-shifted' | 'field'; text: string; field: string; dL: number; contrast: number};
const colorRole = (c: string, bg: string, target: number, shownOf: (x: string) => string = (x) => x, neutral?: string): Role => {
if (contrastHex(shownOf(c), bg) >= target) return {role: 'text', text: c, field: c, dL: 0, contrast: +contrastHex(shownOf(c), bg).toFixed(2)};
const x = inkFor(c, bg, target, shownOf), dL = hexOklch(x).L - hexOklch(c).L;
if ((dL < 0 && -dL <= SHIFT_DOWN) || (dL > 0 && dL <= SHIFT_UP)) return {role: 'text-shifted', text: x, field: c, dL: +dL.toFixed(3), contrast: +contrastHex(shownOf(x), bg).toFixed(2)};
// 면 전용: 글자 = 먹색(주어지면 그 색) 또는 보색 계열(색상각 +180°, 대비가 맞는 밝기) 중 대비가 큰 쪽 — 기본은 먹색
const o = hexOklch(c), dark = lumHex(bg) > 0.18;
const muk = neutral ?? (dark ? mukOf(c) : oklchHex(0.95, 0.01, o.h));
const comp = inkFor(oklchHex(dark ? 0.42 : 0.86, Math.min(0.14, o.C), o.h + Math.PI), bg, target, shownOf);
const t = contrastHex(shownOf(muk), bg) >= target || contrastHex(shownOf(muk), bg) >= contrastHex(shownOf(comp), bg) ? muk : comp;
return {role: 'field', text: t, field: c, dL: +dL.toFixed(3), contrast: +contrastHex(shownOf(t), bg).toFixed(2)};
};
// ── 14차(batch4 4회차) 인쇄 농도 판: 넓은 잉크 면·큰 글자는 깨끗한 단색 잉크 ──
// 3회차의 잉크 부족 세로 줄무늬·빈 점이 띠·블록·큰 글자 위에서 "낡은 복사물·스캔 자국·얼룩"으로 읽혔다(검수 두 건 모두 ③⑦ 감점, 캐러셀 점·얼룩 관문 실패).
// 이제 농도 판은 아주 낮은 주파수 두 성분뿐이다(고주파 패턴 없음):
// ① 판 압력 차이 — seed 로 고른 한쪽 가장자리로 갈수록 최대 −6%(화면 폭의 절반에 걸쳐 부드럽게)
// ② 큰 농도 얼룩 — 판 크기의 35% 크기 얼룩, 최대 −3.5%
// 줄무늬·빈 점·고운 얼룩은 만들지 않는다. 가장자리 번짐은 InkDefs(가장자리 바깥에만). 1/4 해상도로 만들어 늘인다(늘이면서 더 부드러워진다).
// 검사(만든 판 자체): 가장 옅은 곳 ≥ 1 − 0.1·press, 이웃 칸(4px) 사이 차이 ≤ 0.004(판이 화면보다 작으면 1350/판 크기 배), 타일 평균 폭 ≥ 0.015(압력 차이가 있다). 실패 = ink-press.
// 완성 PNG 에서 다시 잰다 — render.mjs 의 잉크 면 검사(inkTexture, 실패 = ink-texture)
const vnoise = (x: number, y: number, s: number) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const a = hash3(xi, yi, s), b = hash3(xi + 1, yi, s), c = hash3(xi, yi + 1, s), d = hash3(xi + 1, yi + 1, s);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
};
const fbm = (x: number, y: number, s: number, oct = 3) => { let t = 0, amp = 0.5, f = 1, n = 0; for (let k = 0; k < oct; k++) { t += amp * vnoise(x * f, y * f, s + k * 17); n += amp; amp *= 0.5; f *= 2; } return t / n; };
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
type PressOpt = {press?: number};
type PressStats = {min: number; max: number; spread: number; maxStep: number; ok: boolean; fail: string[]};
const pressMask = (w: number, h: number, seed: number, opt: PressOpt = {}) => {
const {press = 1} = opt;
const Q = 4, mw = Math.ceil(w / Q), mh = Math.ceil(h / Q), cov = new Float32Array(mw * mh), S = Math.max(w, h) * 0.35;
const side = Math.floor(hash3(seed, 1, 99) * 4); // 압력이 약한 가장자리: 0 위 · 1 오른쪽 · 2 아래 · 3 왼쪽
for (let y = 0; y < mh; y++) for (let x = 0; x < mw; x++) {
const X = x * Q, Y = y * Q;
const t = side === 0 ? Y / h : side === 1 ? 1 - X / w : side === 2 ? 1 - Y / h : X / w; // 0 = 약한 가장자리
const g = clamp01(t / 0.5), P1 = 1 - 0.06 * press * (1 - g * g * (3 - 2 * g));
const P2 = 1 - 0.035 * press * clamp01((fbm(X / S, Y / S, seed + 3, 2) - 0.5) * 2.5 + 0.5);
cov[y * mw + x] = P1 * P2;
}
let mn = 1, mx = 0, step = 0;
for (let y = 0; y < mh; y++) for (let x = 0; x < mw; x++) {
const k = y * mw + x, c = cov[k]; mn = Math.min(mn, c); mx = Math.max(mx, c);
if (x + 1 < mw) step = Math.max(step, Math.abs(c - cov[k + 1]));
if (y + 1 < mh) step = Math.max(step, Math.abs(c - cov[k + mw]));
}
const TX = 6, TY = 8, tm = new Float64Array(TX * TY), tc = new Float64Array(TX * TY);
for (let y = 0; y < mh; y++) for (let x = 0; x < mw; x++) { const tI = Math.min(TY - 1, ((y / mh) * TY) | 0) * TX + Math.min(TX - 1, ((x / mw) * TX) | 0); tm[tI] += cov[y * mw + x]; tc[tI]++; }
const tiles = [...tm].map((v, i) => v / tc[i]);
const st = {min: +mn.toFixed(3), max: +mx.toFixed(3), spread: +(Math.max(...tiles) - Math.min(...tiles)).toFixed(3), maxStep: +step.toFixed(4)};
const fail: string[] = [], minOk = +(1 - 0.1 * press).toFixed(3), stepOk = +(0.004 * Math.max(1, 1350 / Math.max(w, h))).toFixed(4); // 작은 판(도장)은 같은 비율 변화라도 칸 사이 차이가 크다
if (st.min < minOk) fail.push(`너무 옅음(가장 옅은 곳 < ${minOk})`);
if (st.maxStep > stepOk) fail.push(`고주파 농도 변화(이웃 칸 차이 > ${stepOk})`);
if (st.spread < 0.015) fail.push('압력 차이 없음(타일 평균 폭 < 0.015)');
const c = document.createElement('canvas'); c.width = mw; c.height = mh;
const g = c.getContext('2d')!, id = g.createImageData(mw, mh);
for (let k = 0; k < mw * mh; k++) { id.data[k * 4 + 3] = Math.round(255 * clamp01(cov[k])); }
g.putImageData(id, 0, 0);
return {url: c.toDataURL('image/png'), stats: {...st, ok: fail.length === 0, fail} as PressStats};
};
// ── 14차 종이 판 정리(tidyPlate): 판 자체의 줄무늬·튀는 점을 판 분석·정리(cleanPlateFor540) 전에 걷어 낸다 ──
// ① 세로·가로 줄: 고역(판 − 흐린 판)의 열 평균(가로 띠 150px 마다)·행 평균(세로 띠마다)에서 31px 보다 짧은 변화만 빼 준다(긴 밝기 변화는 둔다)
// ② 튀는 점: 고역을 ±0.04 에서 부드럽게 눌러(tanh) 0.75 배 — 결은 남고 점·긴 섬유 끝의 튀는 값은 사라진다
// 밝기 비율로 RGB 를 같이 옮겨 색은 그대로. 결과 = W×H PNG(화면 크기에 덮어 그린 판) + 정리 전후 지표
const tidyPlate = 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);
const b0 = document.createElement('canvas'); b0.width = W; b0.height = H;
const gb = b0.getContext('2d', {willReadFrequently: true})!; gb.filter = 'blur(5px)'; gb.drawImage(c, 0, 0);
const b = gb.getImageData(0, 0, W, H).data;
const Y = new Float32Array(W * H), d = new Float32Array(W * H);
for (let p = 0; p < W * H; p++) { const k = p * 4; Y[p] = (0.2126 * a.data[k] + 0.7152 * a.data[k + 1] + 0.0722 * a.data[k + 2]) / 255; d[p] = Y[p] - (0.2126 * b[k] + 0.7152 * b[k + 1] + 0.0722 * b[k + 2]) / 255; }
const smooth1 = (v: Float64Array, r: number) => { const o = new Float64Array(v.length); for (let x = 0; x < v.length; x++) { let s = 0, n = 0; for (let k = Math.max(0, x - r); k <= Math.min(v.length - 1, x + r); k++) { s += v[k]; n++; } o[x] = s / n; } return o; };
const stripeOf = () => { let cs = 0, rs = 0; const col = new Float64Array(W), row = new Float64Array(H); for (let p = 0; p < W * H; p++) { col[p % W] += d[p]; row[(p / W) | 0] += d[p]; } col.forEach((v) => (cs += (v / H) ** 2)); row.forEach((v) => (rs += (v / W) ** 2)); return {col: Math.sqrt(cs / W), row: Math.sqrt(rs / H)}; };
const dotsOf = () => { let n = 0; for (let p = 0; p < W * H; p++) if (Math.abs(d[p]) > 0.06) n++; return n; };
const before = {...stripeOf(), dots: dotsOf()};
const BAND = 150;
for (let y0 = 0; y0 < H; y0 += BAND) { // 세로 줄(열 평균) — 가로 띠마다
const y1 = Math.min(H, y0 + BAND), col = new Float64Array(W);
for (let y = y0; y < y1; y++) for (let x = 0; x < W; x++) col[x] += d[y * W + x] / (y1 - y0);
const sm = smooth1(col, 15);
for (let y = y0; y < y1; y++) for (let x = 0; x < W; x++) d[y * W + x] -= col[x] - sm[x];
}
for (let x0 = 0; x0 < W; x0 += BAND) { // 가로 줄(행 평균) — 세로 띠마다
const x1 = Math.min(W, x0 + BAND), row = new Float64Array(H);
for (let y = 0; y < H; y++) for (let x = x0; x < x1; x++) row[y] += d[y * W + x] / (x1 - x0);
const sm = smooth1(row, 15);
for (let y = 0; y < H; y++) for (let x = x0; x < x1; x++) d[y * W + x] -= row[y] - sm[y];
}
for (let p = 0; p < W * H; p++) {
const k = p * 4, base = (0.2126 * b[k] + 0.7152 * b[k + 1] + 0.0722 * b[k + 2]) / 255;
d[p] = 0.75 * 0.04 * Math.tanh(d[p] / 0.04);
const y2 = base + d[p], r = y2 / Math.max(1e-4, Y[p]);
a.data[k] = Math.min(255, a.data[k] * r); a.data[k + 1] = Math.min(255, a.data[k + 1] * r); a.data[k + 2] = Math.min(255, a.data[k + 2] * r);
}
const after = {...stripeOf(), dots: dotsOf()};
g.putImageData(a, 0, 0);
const r4 = (q: {col: number; row: number; dots: number}) => ({col: +q.col.toFixed(5), row: +q.row.toFixed(5), dots: q.dots});
return {url: c.toDataURL('image/png'), stats: {before: r4(before), after: r4(after)}};
};
// ── 14차 잉크 면 마스크 렌더: render.mjs 가 같은 장면을 props.inkMask = true 로 한 번 더 그린다 ──
// 잉크 면(띠·블록·하이라이트·큰 글자)은 면마다 다른 순색, 나머지(종이·작은 글자·괘선·사진)는 검정, 필터·마스크·섞기·농도 판은 모두 끈다.
// render.mjs 는 순색이 같은 자리 안쪽(가장자리 5px 제외)만 골라 완성 PNG 의 고주파 세기·세로/가로 줄·고립 점을 잰다(1080 과 540 축소본)
const ZONE_COLORS = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#FF8000', '#80FF00', '#0080FF', '#FF0080', '#8000FF', '#00FF80'];
const zoneNames: string[] = [];
// 잉크 면 색: 마스크 렌더면 그 면의 순색, 아니면 원래 색
const zc = (mask: boolean | undefined, name: string, color: string) => { if (!mask) return color; let i = zoneNames.indexOf(name); if (i < 0) { zoneNames.push(name); i = zoneNames.length - 1; } return ZONE_COLORS[i % ZONE_COLORS.length]; };
const INK_MASK_CSS = `.inkmask, .inkmask * { filter: none !important; mix-blend-mode: normal !important; -webkit-mask-image: none !important; mask-image: none !important; opacity: 1 !important; text-shadow: none !important; box-shadow: none !important; border-color: #000 !important; outline-color: #000 !important; transition: none !important; }
.inkmask *:not([data-ink]) { color: #000 !important; background-color: transparent !important; background-image: none !important; }
.inkmask [data-ink] * { color: inherit !important; }
.inkmask svg *:not([data-ink]) { fill: #000 !important; stroke: #000 !important; }
.inkmask img { display: none !important; }`;
// 마스크 렌더 표지: <style> + 면 이름표(DUCHAMP_INKZONES — render.mjs 가 읽는다)
const InkMaskStyle: React.FC<{on?: boolean; ready: boolean}> = ({on, ready}) => {
useEffect(() => { if (on && ready) setTimeout(() => console.log('DUCHAMP_INKZONES ' + JSON.stringify(Object.fromEntries(zoneNames.map((n, i) => [ZONE_COLORS[i % ZONE_COLORS.length], n])))), 0); }, [on, ready]);
return on ? <style>{INK_MASK_CSS}</style> : null;
};
// 농도 판을 그 잉크 층에 씌운다(CSS 마스크, 층 크기에 늘임). 렌더 전에 loadImage 로 미리 풀어 둔다. 마스크 렌더면 씌우지 않는다
const pressStyle = (url: string | null | undefined): React.CSSProperties => (url ? {WebkitMaskImage: `url(${url})`, maskImage: `url(${url})`, WebkitMaskSize: '100% 100%', maskSize: '100% 100%', WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat'} : {});
// 잉크 층 필터(id = ink<k>): ① 판 가장자리 거칠기 ② 가장자리 번짐 — 낮은 주파수 구역 마스크 안에서만(어떤 곳은 번지고 어떤 곳은 깨끗). 화면 전체 고운 빈 점은 없다
const InkDefs: React.FC<{k: number | string; seed: number; rough?: number; bleed?: number}> = ({k, seed, rough = 1.5, bleed = 1}) => (
<svg width={0} height={0} style={{position: 'absolute'}}>
<filter id={'ink' + k} x="-3%" y="-3%" width="106%" height="106%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.62" numOctaves={1} seed={seed} result="e" />
<feDisplacementMap in="SourceGraphic" in2="e" scale={rough} xChannelSelector="R" yChannelSelector="G" result="rough" />
<feGaussianBlur in="rough" stdDeviation={1.3} result="bl" />
<feComponentTransfer in="bl" result="halo"><feFuncA type="linear" slope={0.55 * bleed} /></feComponentTransfer>
<feTurbulence type="fractalNoise" baseFrequency="0.0045" 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 5 0 0 0 -2.2" result="zone" />
<feComposite in="halo" in2="zone" operator="in" result="haloZ" />
<feMerge><feMergeNode in="haloZ" /><feMergeNode in="rough" /></feMerge>
</filter>
</svg>
);
// ── Duchamp 코드 레시피 · hiring-type-carousel (5장 · 장마다 C 짧게: 등장 1.4s + 분위기 4s) ──
// 사진 없이 타이포로 서는 채용 캐러셀. 판은 종이 한 장(글자 없는 무코팅 종이)만 — 장마다 다른 자리로 잘라 장의 바탕색으로 물들인다.
// 1장 = 포인트색 바탕 + 직무명 초대형(Hahmlet 900, 폭에 꽉 차게. 띄어쓰기가 있으면 낱말마다 한 줄, 띄어쓰기 없는 한 낱말은 쌓지 않고 한 줄 — 넘치면 크기를 줄인다) + 한 줄 + 브랜드
// (선택) 사용자가 준 작업장 사진 1장을 1장 바탕으로만 쓴다 — 흑백으로 바꿔 포인트색 단색 인쇄처럼 물들이고, 글자는 종이색
// 2~4장 = 종이 바탕 + 같은 크기 큰 낱말(업무·조건·복지 등, 포인트색) + 번호 붙은 줄 1~4개(세 장 같은 글자 크기, 괘선 사이) — 브랜드 금지
// 5장 = 색면 뒤집기(바탕 = 글자색) + 같은 크기 큰 낱말(지원 등) + 지원 방법 줄 1~3개 + 맨 아래 브랜드 맺음 줄
// 1장 맨 아래에는 차례 줄(02 업무 · 03 조건 · 04 복지 · 05 지원 — 2~5장 큰 낱말을 그대로 다시 씀)
// 2회차 규칙: 2~5장 큰 낱말 기준선·목록 시작 높이는 네 장 공통(넘길 때 줄이 튀지 않게). 줄이 적어도 행을 벌리지 않고 위에 붙인다.
// 색 대비 하한(WCAG): 큰 글자 ≥ 3(5장 뒤집힌 장은 ≥ 5.5), 번호·줄 글자 ≥ 4.5. 줄은 오른쪽 여백 36px 안(넘치면 줄인다).
// 3회차 규칙: ① 색 역할(colorRole, 밝기 기준) — 포인트색이 밝아(노랑 등) 대비를 맞추려면 많이 어둡게 눌러야 하면 글자색으로 쓰지 않는다(올리브·겨자로 탁해짐).
// 그때 2~5장 큰 낱말은 포인트색 면(블록) 위에 글자색으로 찍는다 — 1장 바탕·2~5장 블록이 같은 포인트색이라 다섯 장 브랜드 색 인상이 같다. 번호도 글자색.
// ② 목록 채우기: 2~5장 모두 같은 규칙 — 목록 칸(공통 시작 높이 ~ 아래 여백)을 줄 수로 나눠 채운다(칸 = 높이 / 줄 수). 글자 크기는 줄 수별 상한(1 110 · 2 92 · 3 78 · 4 64px)과
// 폭으로 정한다. 항목이 2개인 장도 아래 1/3 이 비지 않는다.
// 4회차 규칙: ① 질감 — 큰 글자·포인트색 블록은 깨끗한 단색 잉크. 아주 낮은 주파수 압력 차이(pressMask)와 가장자리 바깥 번짐만(3회차 세로 줄무늬·빈 점 삭제 —
// 노랑 블록 위 검은 점 줄, '제과' 획 안 세로 줄무늬로 점·얼룩 관문 실패). 종이 섬유·얼룩·그레인은 잉크 아래(종이에만). 1장 제목이 바탕보다 밝으면 농도 판 없음.
// 완성 PNG 의 잉크 면 안쪽 고주파·줄·점은 render.mjs 가 잰다(ink-texture)
// ② 2~5장 변주 — 큰 낱말(과 블록)을 짝수 장은 왼쪽, 홀수 장은 오른쪽에 붙인다(같은 서체·크기·기준선·목록 그리드). 블록은 붙은 쪽 화면 끝까지 나간다(3회차: 같은 틀 반복이 단조롭다는 검수 지적)
type Sec = {word: string; lines: string[]};
type Props = {
slide: number; brand: string; title: string; line: string;
sections: Sec[]; last: Sec;
colors: {paper: string; ink: string; accent: string}; plate: string; photo: string | null;
inkMask?: boolean; // render.mjs 가 잉크 면 검사용으로만 켠다(직접 쓰지 않음)
};
const defaults: Props = {
slide: 1, brand: '밀알 제빵소', title: '제빵사', line: '새벽을 같이 여실 분을 찾습니다',
sections: [
{word: '업무', lines: ['새벽 반죽과 굽기', '매장 진열과 포장', '재료 손질과 정리']},
{word: '조건', lines: ['주 5일 · 새벽 5시 출근', '경력 1년 이상', '수습 3개월']},
{word: '복지', lines: ['점심 식사 제공', '명절 상여', '매달 빵 교육']},
],
last: {word: '지원', lines: ['work@milal.kr', '10월 31일까지']},
colors: {paper: '#F1EBDF', ink: '#1E1C1A', accent: '#F0592A'}, plate: 'plates/plate.jpg', photo: null,
};
const L = 68, R = W - 68, BAND = 112, TOP_RULE = 176, BOTTOM = H - 68;
const DISP = 'Hahmlet', DISP_W = 900, DISP_FB = 'Noto Sans KR', SANS = 'IBM Plex Sans KR', MONO = 'IBM Plex Mono'; // 큰 글자 = Hahmlet 900(각진 획 끝의 디스플레이 고딕)
const FONT_CSS = ['https://fonts.googleapis.com/css2?family=Hahmlet:wght@900&family=Noto+Sans+KR:wght@900&family=IBM+Plex+Sans+KR:wght@500;700&family=IBM+Plex+Mono:wght@700&display=block'];
const TITLE_CAP = 470, TITLE_MIN = 96, WORD_CAP = 360, BLOCK_PAD = 0.1, ROW_CAPS = [110, 92, 78, 64], ROW_RPAD = 36, ROW_CAP = 78, ROW_MIN = 46, LINE_CAP = 112, LINE_MIN = 52;
const NUM_W = 96; // 번호 칸 폭(01·02…)
const INDEX_RULE = BOTTOM - 74; // 1장 맨 아래 차례 줄(02 업무 · 03 조건 …)의 괘선 — 2~5장 큰 낱말을 그대로 다시 쓴다(새 글자 없음)
const SIGN_RULE = BOTTOM - 96; // 5장 맨 아래 브랜드 맺음 줄의 괘선
const rowsBottom = (n: number) => (n === 5 ? SIGN_RULE - 40 : BOTTOM - 40);
const IDX = 36, IDX_NUM = 52, FOOT_BASE = BOTTOM - 14; // 맨 아래 줄 기준선(받침 아랫끝이 여백 안) // 차례 줄 글자 크기, 번호 칸
const indexItems = (secs: {word: string}[]) => secs.map((q, i) => ({no: String(i + 2).padStart(2, '0'), word: q.word, x: L + (i * (R - L)) / 4, w: IDX_NUM + measure(q.word, `700 ${IDX}px "IBM Plex Sans KR"`)}));
const signSize = (b: string) => Math.min(60, fitLine(b, (s) => `700 ${s}px "IBM Plex Sans KR"`, 60, R - L));
const FIBER_OP = 0.7;
const CROPS: Crop[] = [{s: 1, fx: 0.5, fy: 0.5}, {s: 1.1, fx: 0.2, fy: 0.2}, {s: 1.12, fx: 0.8, fy: 0.3}, {s: 1.1, fx: 0.3, fy: 0.8}, {s: 1.08, fx: 0.75, fy: 0.75}];
const TICK_W = 30, TICK_GAP = 9, TICKS_W = 5 * TICK_W + 4 * TICK_GAP;
const sansF = (w: number, s: number) => `${w} ${s}px "${SANS}"`;
// 직무명 줄 나누기: 띄어쓰기가 있으면 낱말마다 한 줄(최대 3줄), 없으면 한 줄 그대로(2회차: "바리 / 스타" 처럼 음절을 쌓으면 띄어쓰기로 읽힌다 — 넘치면 크기만 줄인다)
const titleLines = (t: string) => t.trim().split(/\s+/);
// 한 줄 두 줄 나누기(빈칸에서, 두 줄 폭이 가장 비슷한 자리)
// 꾸미는 말(받침 ㄴ·ㄹ 로 끝나는 낱말: 여실·일할·같은)과 바로 뒤 의존 명사·짧은 사람 낱말(분·곳·것·사람·동료 등) 사이는 끊지 않는다(벌점 1.6배) — "여실 / 분을" 방지.
// 을·를·은·는 같은 조사도 받침 ㄴ·ㄹ 이라 뒤 낱말 목록으로만 가린다(2회차: "만드는 / 일을" 까지 막아 "손으로 / 만드는 일을 배울 분" 처럼 한쪽이 짧아졌다 — Claude 실행이 보고)
const finalOf = (ch: string) => { const c = ch.charCodeAt(0) - 0xac00; return c >= 0 && c < 11172 ? c % 28 : -1; };
const BOUND = /^(분|곳|것|거|수|때|데|줄|자리|사람|동료|친구|이웃|손님)/;
const splitTwo = (t: string, wf: (s: string) => number) => {
const sp = [...t.matchAll(/ /g)].map((m) => m.index!);
if (!sp.length) return null;
const score = (a: string, b: string) => {
const lw = a.split(' ').pop() ?? '', nw = b.split(' ')[0] ?? '';
const mod = [4, 8].includes(finalOf(lw.slice(-1))) && BOUND.test(nw) && [...nw].length <= 3; // 받침 ㄴ(4)·ㄹ(8) + 의존 명사
return Math.max(wf(a), wf(b)) * (mod ? 1.6 : 1) * ([...lw].length === 1 ? 1.5 : 1);
};
return sp.map((i) => [t.slice(0, i), t.slice(i + 1)]).reduce((a, b) => (score(b[0], b[1]) < score(a[0], a[1]) ? b : a));
};
type Row = {text: string; base: number; ruleY: number; size: number};
type Lay = {
ready: boolean; plate: string | null; fiber: string | null; photo: string | null;
disp: string; big: {lines: string[]; size: number; bases: number[]; x: number};
sub: {lines: string[]; size: number; bases: number[]};
rows: Row[]; rowSize: number; rowTop: number; head: number;
block: Rect | null; press: {word: string | null; block: string | null};
};
// 사진(1장 선택): 흑백 → 검정·흰색 지점을 펴고(0.5%·99.5%) 가운데 밝기를 0.32 로(감마) → data URL. 포인트색 곱하기는 화면에서
const tonePhoto = 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 d = g.getImageData(0, 0, W, H), n = W * H, hist = new Float64Array(256), Y = new Float32Array(n);
for (let k = 0; k < n; k++) { const v = (0.2126 * d.data[k * 4] + 0.7152 * d.data[k * 4 + 1] + 0.0722 * d.data[k * 4 + 2]) / 255; Y[k] = v; hist[Math.min(255, (v * 255) | 0)]++; }
const q = (f: number) => { let a = 0; for (let v = 0; v < 256; v++) { a += hist[v]; if (a >= f * n) return v / 255; } return 1; };
const lo = q(0.005), hi = Math.max(lo + 0.05, q(0.995)), mid = Math.min(0.95, Math.max(0.05, (q(0.5) - lo) / (hi - lo)));
const gam = Math.log(0.32) / Math.log(mid);
let sum = 0;
for (let k = 0; k < n; k++) { const v = Math.pow(Math.min(1, Math.max(0, (Y[k] - lo) / (hi - lo))), gam); sum += v; const b = Math.round(v * 255); d.data[k * 4] = d.data[k * 4 + 1] = d.data[k * 4 + 2] = b; }
g.putImageData(d, 0, 0);
return {src: c.toDataURL('image/jpeg', 0.92), mean: sum / n, lo, hi, gamma: +gam.toFixed(3)};
};
const Hiring: React.FC<Props> = (p) => {
const n = p.slide;
const sec = n >= 2 && n <= 4 ? p.sections[n - 2] : n === 5 ? p.last : null;
const [handle] = useState(() => delayRender('fonts+fit'));
const [look, setLook] = useState<ReturnType<typeof plateLook> | null>(null);
const root = React.useRef<HTMLDivElement>(null);
const cover = n === 1, flip = n === 5, usePhoto = cover && !!p.photo;
// 장 바탕: 1장 포인트색, 2~4장 종이색, 5장 글자색(뒤집기). 글자색 = 바탕과 대비가 큰 쪽(종이색/글자색), 큰 낱말 = 포인트색(5장은 포인트색이 바탕과 대비 3 미만이면 종이색)
// 1장: 글자 = 종이색·글자색 중 포인트색과 대비가 큰 쪽, 그래도 4.5 미만이면 바탕(포인트색) 밝기를 옮긴다
const coverText = contrastHex(p.colors.ink, p.colors.accent) >= contrastHex(p.colors.paper, p.colors.accent) ? p.colors.ink : p.colors.paper;
const fill = cover ? (usePhoto ? p.colors.accent : inkFor(p.colors.accent, coverText, 4.5)) : flip ? p.colors.ink : p.colors.paper;
const text = usePhoto ? p.colors.paper : cover ? coverText : flip ? p.colors.paper : p.colors.ink;
// 큰 낱말 = 포인트색(종이 위 ≥ 3, 5장 뒤집힌 바탕 위 ≥ 5.5), 번호 = 포인트색(≥ 4.5). 색 역할은 다섯 장에 한 번 정한다(종이 위·뒤집힌 바탕 위 둘 중 하나라도 '면'이면 면)
const rolePaper = colorRole(p.colors.accent, p.colors.paper, 3, (x) => x, p.colors.ink), roleFlip = colorRole(p.colors.accent, p.colors.ink, 5.5, (x) => x, p.colors.paper);
const accentField = rolePaper.role === 'field' || roleFlip.role === 'field';
const block = !cover && accentField; // 2~5장 큰 낱말 = 포인트색 블록 위 글자색
const wordColor = cover ? text : block ? (flip ? p.colors.ink : text) : flip ? roleFlip.text : rolePaper.text;
const numRole = colorRole(p.colors.accent, fill, 4.5, (x) => x, text);
const numColor = cover || accentField || numRole.role === 'field' ? text : numRole.text;
const side: 'L' | 'R' = cover || n % 2 === 0 ? 'L' : 'R'; // 2·4장 왼쪽, 3·5장 오른쪽(4회차 변주)
const head = cover ? p.brand : p.title; // 위 띠 왼쪽: 1장 브랜드, 2~5장 직무명(2~4장 브랜드 금지, 5장 브랜드는 맨 아래 맺음 줄)
const [, setPitch] = useState(0);
const [lay, setLay] = useState<Lay>({ready: false, plate: null, fiber: null, photo: null, disp: DISP, big: {lines: [], size: 200, bases: [], x: L}, sub: {lines: [], size: 60, bases: []}, rows: [], rowSize: 60, rowTop: 0, head: 1, block: null, press: {word: null, block: null}});
useEffect(() => {
(async () => {
checkSlide(p);
if (p.sections.length !== 3) throw new Error('sections 는 3개(2~4장)');
const all = [...p.sections, p.last];
await loadFonts(FONT_CSS, [
[`${DISP_W} 100px "${DISP}"`, p.title + all.map((s) => s.word).join('')], [`900 100px "${DISP_FB}"`, p.title + all.map((s) => s.word).join('')],
[sansF(700, 100), p.brand + p.line + all.map((s) => s.lines.join('')).join('')], [`700 100px "${MONO}"`, '0123456789'],
]);
const over: string[] = [], warn: string[] = [];
// 글리프 누락: 디스플레이 서체에 없는 음절이 있으면 다섯 장 모두 Noto Sans KR 900 으로(대체 글꼴로 섞어 그리지 않는다)
const dispMiss = glyphMissing(DISP, DISP_W, p.title + all.map((s) => s.word).join(''));
const disp = dispMiss.length ? DISP_FB : DISP, dispW = 900;
if (dispMiss.length) warn.push(`${DISP} 에 없는 글자(${dispMiss.join('')}) → 큰 글자 서체를 ${DISP_FB} 900 으로`);
const sansMiss = glyphMissing(SANS, 700, p.brand + p.line + all.map((s) => s.lines.join('')).join(''));
if (sansMiss.length) over.push('glyph-missing');
const dF = (s: number) => `${dispW} ${s}px "${disp}"`;
const TRACK = -0.02; // 큰 글자 자간(em)
const dw = (t: string, s: number) => measure(t, dF(s)) + TRACK * s * Math.max(0, [...t].length - 1);
// 색 대비
if (contrastHex(p.colors.ink, p.colors.paper) < 4.5) over.push('contrast-ink');
if (fill !== p.colors.accent && cover) warn.push(`1장 바탕 ${p.colors.accent} → 글자 대비 4.5 를 위해 ${fill}`);
if (block) warn.push(`포인트색 ${p.colors.accent} 는 밝은 색 → 2~5장 큰 낱말은 포인트색 블록 위 ${wordColor}(글자색), 번호도 글자색`);
else if (wordColor !== p.colors.accent && !cover) warn.push(`${n}장 큰 낱말 ${p.colors.accent} → 대비 하한을 위해 밝기 ${(flip ? roleFlip : rolePaper).dL} 옮긴 ${wordColor}`);
const wordBg = block ? p.colors.accent : fill;
const contrast = {text: +contrastHex(text, fill).toFixed(2), word: +contrastHex(wordColor, wordBg).toFixed(2), num: +contrastHex(numColor, fill).toFixed(2)};
if (!usePhoto && (contrast.text < 4.5 || contrast.num < 4.5 || contrast.word < (flip && !block ? 5.5 : block ? 4.5 : 3))) over.push('contrast');
// 브랜드 색 일관(5장): 포인트색이 쓰인 자리(1장 바탕·블록·큰 낱말·번호)의 색이 입력 포인트색에서 벗어난 정도 — 면은 그대로(0), 글자는 밝기만 SHIFT 상한 안
const accentUses = [cover ? fill : null, block ? p.colors.accent : null, !cover && !block ? wordColor : null, !cover && numColor !== text ? numColor : null].filter(Boolean) as string[];
const accentDrift = Math.max(0, ...accentUses.map((c) => Math.abs(hexOklch(c).L - hexOklch(p.colors.accent).L)));
if (accentDrift > SHIFT_UP + 0.001) over.push('accent-drift');
const headSize = cover ? 52 : 40;
const headScale = Math.min(1, (R - L - TICKS_W - 40) / measure(head, sansF(700, headSize)));
if (headScale < 0.75) over.push('head');
// 2~5장 큰 낱말 = 네 장 공통 크기(가장 긴 낱말 기준, 최대 300px)
const wordSize = Math.min(WORD_CAP, ...all.map((s) => ((R - L) / (dw(s.word, 100) + (accentField ? 2 * BLOCK_PAD * 100 : 0))) * 100));
const pad = accentField ? BLOCK_PAD * wordSize : 0; // 블록 안쪽 여백(2~5장 공통)
// 2~4장 줄 글자 크기 = 세 장 공통(가장 긴 줄·가장 많은 줄 수 기준). 5장은 5장 줄끼리
const wordInk = (t: string, s: number) => inkOf(t, dF(s));
// 2~5장 공통 기준선·목록 시작(가장 높은 잉크·가장 깊은 받침 기준) — 장마다 낱말 잉크가 달라도 줄이 같은 높이에서 시작한다
const wordCap = Math.max(...all.map((s) => wordInk(s.word, wordSize).asc)), wordDesc = Math.max(...all.map((s) => wordInk(s.word, wordSize).desc));
const wordBase = TOP_RULE + 64 + pad + wordCap, rowsStart = wordBase + wordDesc + pad + 72;
const rowFitW = (l: string) => ((R - L - NUM_W - ROW_RPAD) / measure(l, sansF(700, 100))) * 100;
const longToken = (l: string) => /@|https?:|\S{16,}/.test(l);
// 목록 채우기(3회차, 2~5장 같은 규칙): 칸 = (목록 아래 끝 − 공통 시작) / 줄 수, 글자 = min(줄 수별 상한, 그 장의 가장 긴 줄 폭, 칸 / 2.15)
// 이메일·주소처럼 띄어쓰기 없는 긴 덩어리(@ · http · 공백 없는 16자 이상)가 있는 줄은 크기 계산에서 빼고, 그 줄만 따로 줄인다
const rowsFit = (lines: string[], slideN: number) => {
const k = Math.max(1, lines.length), avail = rowsBottom(slideN) - rowsStart, pitch = avail / k;
const byW = Math.min(...lines.filter((l) => !longToken(l)).map((l) => rowFitW(l)), Infinity);
return {size: Math.min(ROW_CAPS[Math.min(4, k) - 1], byW, pitch / 2.15), pitch, avail};
};
let blk: Rect | null = null;
let big: Lay['big'], sub: Lay['sub'] = {lines: [], size: 0, bases: []}, rows: Row[] = [], rowSize = 0, rowTop = 0;
const gaps: Record<string, number> = {};
if (cover) {
// 1장: 직무명(초대형) → 64px → 한 줄(최대 86px, 길면 두 줄). 덩어리를 위 괘선 ~ 아래 여백 사이 가운데
const tl = titleLines(p.title);
if (tl.length > 3 || [...p.title.replace(/\s/g, '')].length > 10) over.push('title');
const top0 = TOP_RULE + 56, room = INDEX_RULE - 56 - top0;
// 한 줄 상한 = 직무명 크기의 0.5배(긴 한 낱말 직무명이 작아져도 한 줄이 직무명보다 커 보이지 않게, 최소 LINE_MIN)
const T0 = Math.min(TITLE_CAP, ...tl.map((t) => ((R - L) / dw(t, 100)) * 100));
const lineCap = Math.min(LINE_CAP, Math.max(LINE_MIN, T0 * 0.5));
let ls = fitLine(p.line, (s) => sansF(700, s), lineCap, R - L);
let sl = [p.line];
// 두 줄이 1.15배 이상 크게 되면 두 줄(빈칸에서, 두 줄 폭이 가장 비슷한 자리) — 표지가 비지 않게 한 줄을 크게
{ const two = splitTwo(p.line, (s) => measure(s, sansF(700, 100))); if (two) { const s2 = Math.min(lineCap, ...two.map((q) => ((R - L) / measure(q, sansF(700, 100))) * 100)); if (s2 > ls * 1.15) { sl = two; ls = s2; } } }
if (ls < LINE_MIN) over.push('line');
const subInk = (k: number) => inkOf(sl[k], sansF(700, ls));
const subH = (sl.length - 1) * ls * 1.3 + subInk(0).asc + subInk(sl.length - 1).desc;
const pitchK = 1.04; // 큰 글자 줄 간격(em, 잉크 기준)
const capOf = (s: number) => Math.max(...tl.map((t) => wordInk(t, s).asc)), descOf = (s: number) => Math.max(...tl.map((t) => wordInk(t, s).desc));
const hAt = (s: number) => (tl.length - 1) * s * pitchK + capOf(s) + descOf(s);
let T = Math.min(TITLE_CAP, ...tl.map((t) => ((R - L) / dw(t, 100)) * 100));
while (hAt(T) + 64 + subH > room - 40 && T > 60) T -= 4;
if (T < TITLE_MIN) over.push('title');
const blockH = hAt(T) + 64 + subH;
const y0 = top0 + (room - blockH) / 2;
const bases = tl.map((_, i) => y0 + capOf(T) + i * T * pitchK);
big = {lines: tl, size: T, bases, x: L - Math.min(...tl.map((t) => wordInk(t, T).l))};
const s0 = bases[bases.length - 1] + descOf(T) + 64 + subInk(0).asc;
sub = {lines: sl, size: ls, bases: sl.map((_, i) => s0 + i * ls * 1.3)};
gaps.titleLine = 64; gaps.blockTop = Math.round(y0 - top0); gaps.blockBottom = Math.round(room - blockH - (y0 - top0));
} else {
// 2~5장: 큰 낱말(공통 크기) → 줄들(괘선 사이, 덩어리를 남는 높이 가운데)
const base = wordBase;
const wk = wordInk(sec!.word, wordSize), wInk = wk.r - wk.l + TRACK * wordSize * Math.max(0, [...sec!.word].length - 1);
big = {lines: [sec!.word], size: wordSize, bases: [base], x: side === 'L' ? L + pad - wk.l : R - pad - wInk - wk.l};
if (wordSize < 160) over.push('word');
const f = rowsFit(sec!.lines, n);
rowSize = f.size;
if (rowSize < ROW_MIN) over.push('rows');
if (sec!.lines.length < 1 || sec!.lines.length > (flip ? 3 : 4)) over.push('rows-count');
// 목록은 공통 시작 높이에서 시작해 아래 끝까지 채운다(칸 = 남는 높이 / 줄 수)
const top = rowsStart;
const pitch = f.pitch;
const blockH = sec!.lines.length * pitch;
rowTop = top;
if (top + blockH > rowsBottom(n) + 0.5) over.push('rows-height');
rows = sec!.lines.map((t, i) => { const sz = Math.min(rowSize, rowFitW(t)); if (sz < ROW_MIN) over.push('rows'); const ib = inkOf(t, sansF(700, sz)); return {text: t, size: sz, base: rowTop + i * pitch + pitch / 2 + (ib.asc - ib.desc) / 2, ruleY: rowTop + (i + 1) * pitch}; });
setPitch(pitch);
gaps.rowsStart = Math.round(rowTop); gaps.rowPitch = Math.round(pitch); gaps.rowsEnd = Math.round(rowTop + blockH); gaps.rowsBottom = rowsBottom(n);
// 블록: 큰 낱말 + 안쪽 여백, 붙은 쪽은 화면 끝까지(재단선 밖으로 나가는 띠)
if (block) { const y = base - wordCap - pad, h = wordCap + wordDesc + 2 * pad; blk = side === 'L' ? {x: 0, y, w: L + 2 * pad + wInk, h} : {x: R - 2 * pad - wInk, y, w: W - (R - 2 * pad - wInk), h}; }
}
// 인쇄 농도 판: 큰 글자 층(장마다 seed 다름) · 포인트색 블록 층. 1장 제목이 바탕보다 밝으면(찍히지 않은 종이) 농도 판 없음. 큰 글자에는 빈 점을 두지 않는다
const knock = cover && lumHex(text) > lumHex(fill);
const pmWord = knock ? null : pressMask(W, H, 50 + n), pmBlock = blk ? pressMask(W, H, 60 + n) : null;
await Promise.all([pmWord, pmBlock].filter(Boolean).map((q) => loadImage(q!.url)));
const pressBad = [pmWord, pmBlock].filter((q) => q && !q.stats.ok);
if (pressBad.length) { over.push('ink-press'); pressBad.forEach((q) => warn.push(`농도 판 검사 실패: ${q!.stats.fail.join(', ')}`)); }
setLay((o) => ({...o, ready: true, disp, big, sub, rows, rowSize, rowTop, head: headScale, block: blk, press: {word: pmWord?.url ?? null, block: pmBlock?.url ?? null}}));
await settle();
// 글자 상자(잉크) 검사: 여백 안 · 서로 20px 이상
const inkRect = (t: string, font: string, x: number, b: number): Rect => { const k = inkOf(t, font); return {x: x + k.l, y: b - k.asc, w: k.r - k.l, h: k.asc + k.desc}; };
const rects: (Rect & {name: string})[] = [
{name: 'head', ...inkRect(head, sansF(700, headSize * headScale), L, BAND)},
...big.lines.map((t, i) => ({name: 'big' + i, ...inkRect(t, dF(big.size), big.x, big.bases[i])})),
...(blk ? [{name: 'block', ...blk}] : []),
...sub.lines.map((t, i) => ({name: 'line' + i, ...inkRect(t, sansF(700, sub.size), L, sub.bases[i])})),
...rows.map((r, i) => ({name: 'row' + (i + 1), ...inkRect(r.text, sansF(700, r.size), L + NUM_W, r.base)})),
...(cover ? indexItems(all).map((q, i) => ({name: 'index' + (i + 2), ...inkRect(q.word, sansF(700, IDX), q.x + IDX_NUM, FOOT_BASE)})) : []),
...(flip ? [{name: 'sign', ...inkRect(p.brand, sansF(700, signSize(p.brand)), L, FOOT_BASE)}] : []),
];
if (cover && indexItems(all).some((q) => q.w > (R - L) / 4 - 16)) over.push('index');
// 큰 글자는 자간(TRACK)만큼 실제 폭이 좁다 → 캔버스 폭에서 빼 준다
rects.forEach((q) => { if (q.name.startsWith('big')) { const t = big.lines[+q.name.slice(3)]; q.w += TRACK * big.size * Math.max(0, [...t].length - 1); } });
const outside = rects.filter((q) => q.name !== 'block' && !inside(q, L, 40, q.name.startsWith('row') ? R - ROW_RPAD + 1 : R, BOTTOM)).map((q) => q.name); // 블록은 화면 끝까지 나간다
if (outside.length) over.push('margin');
const ov = overlaps(rects, 20).filter((o) => !/^big\d×block|^block×big/.test(o));
if (ov.length) over.push('overlap');
// 빈 띠: 위 괘선 ~ 아래 여백 사이 글자 간 가장 큰 빈 높이(260px 넘으면 warn)
const ys = rects.filter((q) => q.name !== 'head').map((q) => [q.y, q.y + q.h] as [number, number]).sort((a, b) => a[0] - b[0]);
let cov = TOP_RULE, blank = 0;
for (const [a, b] of ys) { if (a > cov) blank = Math.max(blank, a - cov); cov = Math.max(cov, b); }
blank = Math.max(blank, BOTTOM - cov);
if (blank > 260) warn.push(`빈 띠 ${Math.round(blank)}px`);
// 판: 종이 판을 장마다 다른 자리로 잘라 장 바탕색으로(판 전체 점·얼룩 정리 + 540 검사). 1장 사진이 있으면 사진을 쓴다
let tone: Awaited<ReturnType<typeof tonePhoto>> | null = null;
const tp = await tidyPlate(staticFile(p.plate)); // 판 자체의 줄무늬·튀는 점 정리(4회차)
const pa = await analysePlate(tp.url);
const lk = plateLook(pa.mean, pa.lum, fill);
const gain = lk.mode === 'tint' ? 1.6 * (0.5 / Math.max(0.03, pa.lum)) : 1;
const cp = await cleanPlateFor540(tp.url, CROPS[n - 1], [{x: 0, y: 0, w: W, h: H}], gain, lk, fill, FIBER_OP, 5 + n * 7);
if (usePhoto) tone = await tonePhoto(staticFile(p.photo!));
setLook(lk);
setLay((o) => ({...o, plate: cp.plate, fiber: cp.fiber, photo: tone ? tone.src : null}));
await settle();
if (cp.specks.after > 0) over.push('plate-specks');
if (cp.specks540.count > 0) over.push('specks-540');
if (cp.texture.cv < 0.2) over.push('texture-uniform');
const leaks = n >= 2 && n <= 4 ? brandLeaks(p.brand, [p.title, sec!.word, ...sec!.lines]) : [];
if (leaks.length) over.push('brand-2to4');
const hint = over.length ? '넘침·규칙 위반 = ' + over.join(', ') + (leaks.length ? ' (2~4장에는 브랜드명을 쓰지 않는다: ' + leaks.join(' | ') + ')' : '') + (over.some((o) => /plate-specks|specks-540|texture-uniform|ink-press|overlap|margin/.test(o)) ? ' / 판 점·질감·배치 검사 실패는 코드 문제다 → 고치지 말고 check.json 을 그대로 보고' : ' → 사용자에게 줄이거나 고친 안을 물어본 뒤 props.json 을 고쳐 다시') : '';
report({slide: n, ok: over.length === 0, overflow: over, ...(warn.length ? {warn} : {}), plateMode: lk.mode, fill, text, wordColor, numColor, contrast, display: {family: disp, missing: dispMiss},
big: {lines: big.lines, size: Math.round(big.size)}, ...(cover ? {line: {lines: sub.lines, size: Math.round(sub.size)}} : {rows: {count: rows.length, size: Math.round(rowSize), lineSizes: rows.map((r) => Math.round(r.size)), commonWordSize: Math.round(wordSize)}}),
accent: {field: accentField, block: !!blk, rolePaper, roleFlip, drift: +accentDrift.toFixed(3)}, press: {word: pmWord?.stats ?? null, block: pmBlock?.stats ?? null}, side, plateTidy: tp.stats,
gaps, maxBlankPx: Math.round(blank), boxes: rects.map((q) => ({n: q.name, x: Math.round(q.x), y: Math.round(q.y), w: Math.round(q.w), h: Math.round(q.h)})), boxOverlaps: ov, outside,
...(usePhoto ? {photo: {mean: +tone!.mean.toFixed(3), gamma: tone!.gamma}} : {}), specks: cp.specks, specks540: cp.specks540, texture: cp.texture, hint});
await settle();
continueRender(handle);
})().catch((e) => cancelRender(e));
}, []);
const frame = useCurrentFrame();
const dispW = 900;
const roll = (s: number, len = 12): React.CSSProperties => {
const t = ramp(frame, s, s + len, Easing.bezier(0.4, 0, 0.2, 1));
const edge = -80 + t * (W + 160); // 마스크 좌표 = 요소 상자 기준 → 화면 폭 전체를 지나가게(끝 프레임에 오른쪽 끝이 흐려지지 않게)
const m = `linear-gradient(90deg, #000 ${edge - 80}px, transparent ${edge}px)`;
return {WebkitMaskImage: m, maskImage: m};
};
const q = (k: number) => pressK(n, k);
const pr = (k: number): React.CSSProperties => ({filter: `url(#print) drop-shadow(0 ${(-1 * q(k)).toFixed(2)}px 0.5px rgba(0,0,0,${(0.18 * q(k)).toFixed(3)}))`});
const at = (t: string, font: string, base: number, size: number) => { const f = inkOf(t, font); return base - (size - (f.fa + f.fd)) / 2 - f.fa; };
const headSize = (cover ? 52 : 40) * lay.head;
const M = !!p.inkMask; // 잉크 면 검사용 마스크 렌더(render.mjs)
return (
<AbsoluteFill ref={root} className={M ? 'inkmask' : undefined} style={{backgroundColor: M ? '#000' : fill, overflow: 'hidden', opacity: lay.ready ? 1 : 0}}>
<InkMaskStyle on={M} ready={lay.ready} />
{!M && look && lay.plate && !lay.photo && <Img src={lay.plate} style={{...look.style, ...cropStyle({s: 1, fx: 0.5, fy: 0.5})}} />}
{!M && lay.photo && (
<>
{/* 사진 = 흑백 톤 → 포인트색 곱하기(단색 인쇄처럼) → 위·아래 어둡힘(글자 자리) */}
<Img src={lay.photo} style={{position: 'absolute', width: W, height: H}} />
<AbsoluteFill style={{backgroundColor: p.colors.accent, mixBlendMode: 'multiply'}} />
<AbsoluteFill style={{background: `linear-gradient(180deg, ${rgba(p.colors.ink, 0.55)} 0%, ${rgba(p.colors.ink, 0.2)} 30%, ${rgba(p.colors.ink, 0.25)} 60%, ${rgba(p.colors.ink, 0.7)} 100%)`}} />
</>
)}
{/* 종이 섬유·얼룩·그레인은 잉크 아래(4회차) — 큰 글자·블록 위로 결이 비치지 않게 */}
{!M && look && lay.fiber && <Img src={lay.fiber} style={{position: 'absolute', width: W, height: H, mixBlendMode: 'overlay', opacity: lay.photo ? 0.35 : FIBER_OP}} />}
{!M && <PaperMottle seed={31 + n} opacity={0.28} />}
{!M && <Grain opacity={lay.photo ? 0.08 : 0.03} />}
<PrintDefs seed={21 + n} spread={0.7} density={0.5} />
<InkDefs k={'W' + n} seed={40 + n} rough={1.5} />
<InkDefs k={'K' + n} seed={60 + n} rough={1.3} bleed={0.8} />
{/* 위 띠: 왼쪽 브랜드(1·5장) / 직무명(2~4장), 오른쪽 쪽 표시(막대 5개) */}
<div data-t style={{position: 'absolute', left: L, top: at(head, sansF(700, headSize), BAND, headSize), font: sansF(700, headSize), lineHeight: 1, whiteSpace: 'pre', width: 'max-content', color: text, opacity: (cover ? 1 : 0.78) * ramp(frame, 2, 8), ...pr(1)}}>{head}</div>
<div style={{position: 'absolute', left: R - TICKS_W, top: BAND - 18, width: TICKS_W, height: 6, opacity: ramp(frame, 2, 8)}}>
{Array.from({length: N}, (_, i) => <div key={i} style={{position: 'absolute', left: i * (TICK_W + TICK_GAP), top: 0, width: TICK_W, height: 6, background: text, opacity: i === n - 1 ? 0.92 : 0.32}} />)}
</div>
<div style={{position: 'absolute', left: L, top: TOP_RULE - 1.5, width: R - L, height: 3, background: text, opacity: 0.85, transformOrigin: '0 50%', transform: `scaleX(${ramp(frame, 0, 14, Easing.bezier(0.3, 0, 0.2, 1))})`, filter: 'url(#print)'}} />
{/* 포인트색 블록(밝은 포인트색일 때 2~5장): 왼쪽에서 오른쪽으로 찍힌다 */}
{lay.block && (
<div style={{position: 'absolute', inset: 0, filter: `url(#inkK${n})`}}>
<div style={{position: 'absolute', inset: 0, ...pressStyle(lay.press.block)}}>
<div data-ink style={{position: 'absolute', left: lay.block.x, top: lay.block.y, width: lay.block.w, height: lay.block.h, background: zc(M, 'block', p.colors.accent), transformOrigin: side === 'L' ? '0 50%' : '100% 50%', transform: `scaleX(${ramp(frame, 1, 13, Easing.bezier(0.3, 0, 0.2, 1))})`}} />
</div>
</div>
)}
{/* 큰 글자: 아래에서 올라온다(줄마다 3f 시차, 줄 상자 밖은 가림). 한 층에 모아 잉크 필터 + 인쇄 농도 판 */}
{/* 4회차: 같은 색 1.2px 윤곽(text-stroke) — 획이 맞닿는 자리에 생기는 1px 밝은 이음매(글꼴 윤곽 겹침)를 덮는다(잉크 면 검사가 '고립 점'으로 잡음) */}
<div style={{position: 'absolute', inset: 0, filter: `url(#inkW${n})`}}>
<div style={{position: 'absolute', inset: 0, ...pressStyle(lay.press.word)}}>
{lay.big.lines.map((t, i) => {
const f = inkOf(t, `${dispW} ${lay.big.size}px "${lay.disp}"`);
const up = enter(frame, 4 + i * 3, 16);
return (
<div key={i} style={{position: 'absolute', left: lay.big.x - 20, top: lay.big.bases[i] - f.asc - 20, width: R - L + 40, height: f.asc + f.desc + 40, overflow: 'hidden'}}>
<div data-t data-ink style={{position: 'absolute', left: 20, top: 20 + f.asc - (lay.big.size - (f.fa + f.fd)) / 2 - f.fa + up * (f.asc + f.desc + 40), fontFamily: `"${lay.disp}"`, fontWeight: dispW, fontSize: lay.big.size, lineHeight: 1, letterSpacing: '-0.02em', whiteSpace: 'pre', width: 'max-content', color: zc(M, 'word', wordColor), WebkitTextStroke: '1.2px currentColor'}}>{t}</div>
</div>
);
})}
</div>
</div>
{/* 1장 한 줄 */}
{lay.sub.lines.map((t, i) => (
<div key={'s' + i} data-t style={{position: 'absolute', left: L, top: at(t, sansF(700, lay.sub.size), lay.sub.bases[i], lay.sub.size), font: sansF(700, lay.sub.size), lineHeight: 1, whiteSpace: 'pre', width: 'max-content', color: text, ...roll(20 + i * 4), ...pr(2 + i)}}>{t}</div>
))}
{/* 1장 맨 아래 차례 줄: 02 업무 · 03 조건 · 04 복지 · 05 지원 (2~5장 큰 낱말 그대로) */}
{cover && lay.ready && (
<div style={{position: 'absolute', inset: 0, ...roll(26)}}>
<div style={{position: 'absolute', left: L, top: INDEX_RULE - 1.5, width: R - L, height: 3, background: text, opacity: 0.85, filter: 'url(#print)'}} />
{indexItems([...p.sections, p.last]).map((q, i) => (
<React.Fragment key={i}>
<div style={{position: 'absolute', left: q.x, top: at(q.no, `700 ${IDX * 0.72}px "${MONO}"`, FOOT_BASE, IDX * 0.72), font: `700 ${IDX * 0.72}px "${MONO}"`, lineHeight: 1, color: text, opacity: 0.7, ...pr(20 + i)}}>{q.no}</div>
<div data-t style={{position: 'absolute', left: q.x + IDX_NUM, top: at(q.word, sansF(700, IDX), FOOT_BASE, IDX), font: sansF(700, IDX), lineHeight: 1, whiteSpace: 'pre', width: 'max-content', color: text, ...pr(24 + i)}}>{q.word}</div>
</React.Fragment>
))}
</div>
)}
{/* 5장 맨 아래 브랜드 맺음 줄 */}
{flip && lay.ready && (
<div style={{position: 'absolute', inset: 0, ...roll(30)}}>
<div style={{position: 'absolute', left: L, top: SIGN_RULE - 1.5, width: R - L, height: 3, background: text, opacity: 0.85, filter: 'url(#print)'}} />
<div data-t style={{position: 'absolute', left: L, top: at(p.brand, sansF(700, signSize(p.brand)), FOOT_BASE, signSize(p.brand)), font: sansF(700, signSize(p.brand)), lineHeight: 1, whiteSpace: 'pre', width: 'max-content', color: text, ...pr(30)}}>{p.brand}</div>
</div>
)}
{/* 2~5장 줄: 번호 + 글자, 줄마다 아래 괘선 */}
{lay.rows.map((r, i) => (
<div key={'r' + i} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, ...roll(16 + i * 5)}}>
<div style={{position: 'absolute', left: L, top: lay.rowTop - 1, width: R - L, height: 2, background: text, opacity: i === 0 ? 0.5 : 0, filter: 'url(#print)'}} />
<div data-t style={{position: 'absolute', left: L, top: at('01', `700 ${lay.rowSize * 0.5}px "${MONO}"`, r.base, lay.rowSize * 0.5), font: `700 ${lay.rowSize * 0.5}px "${MONO}"`, lineHeight: 1, color: numColor, ...pr(4)}}>{String(i + 1).padStart(2, '0')}</div>
<div data-t style={{position: 'absolute', left: L + NUM_W, top: at(r.text, sansF(700, r.size), r.base, r.size), font: sansF(700, r.size), lineHeight: 1, whiteSpace: 'pre', width: 'max-content', color: text, ...pr(5 + i)}}>{r.text}</div>
{/* 5장 마지막 줄 아래 괘선은 맺음 줄 괘선과 100px 안이면 긋지 않는다(1회차: 두 선이 40px 간격으로 겹쳐 보임 — Claude 실행이 보고) */}
{!(flip && i === lay.rows.length - 1 && SIGN_RULE - r.ruleY < 100) && <div style={{position: 'absolute', left: L, top: r.ruleY - 1, width: R - L, height: 2, background: text, opacity: 0.5, filter: 'url(#print)'}} />}
</div>
))}
{!M && <Sweep a={0.4} />}
</AbsoluteFill>
);
};
registerRoot(() => <Composition id="Main" component={Hiring} 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` 에는 그 장만 남으니, 끝내기 전에는 번호 없이 다섯 장을 다시 렌더한다).
- 장마다 PNG 다음에 같은 장을 잉크 면 검사용으로 한 번 더 렌더한다(`out/s<n>-inkmask.png` — 결과물 아님).
- 렌더 중 브라우저가 폰트·판 이미지를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
- **종료 코드 2 = 검사 실패 → 거기서 멈춘다**(MP4 를 만들지 않는다). `out/check.json` 의 장별 `overflow`·`hint` 를 읽고 원인을 고친 뒤 다시 실행한다. 글자를 줄이거나 바꿔야 하면 마음대로 고치지 말고 사용자에게 고친 안을 물어본다.
## 7. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: 맨 위 `ok: true`, 다섯 장 모두 `ok: true`·`overflow: []`. `overflow` 뜻: `title`(직무명이 길어 96px 아래, 또는 낱말 3개·10자 초과)·`line`(한 줄이 길어 52px 아래)·`word`(큰 낱말이 길어 160px 아래)·`rows`(줄이 길어 46px 아래)·`rows-count`(줄 수가 1~4개, 5장 1~3개 밖)·`rows-height`·`index`(차례 줄 낱말이 칸보다 김) — 줄여 달라고, `head`(위 띠 글자가 김), `brand-2to4`(2~4장에 브랜드명 — 뺀 안을 물어본다), `contrast-ink`(글자색이 종이와 대비 4.5 미만)·`contrast`(밝기를 옮겨도 대비 하한 미달) — 색을 바꿔 달라고, `glyph-missing`(서체에 없는 글자), `accent-drift`(포인트색이 장마다 다른 색으로 보일 만큼 밝기가 옮겨짐), `plate-specks`·`specks-540`(판 점이 남음)·`texture-uniform`(종이 결이 균일)·`ink-press`(잉크 농도 판에 고주파 변화가 생김 — 농도 판 검사)·`ink-texture`(완성 PNG 의 잉크 면 — 띠·블록·하이라이트·큰 글자 — 안쪽에 고주파 결·세로/가로 줄·고립 점이 상한을 넘음, 1080·540 둘 다 잰다)·`overlap`(글자끼리 20px 안)·`margin`(여백 밖) — 이 일곱은 코드 문제이니 고치지 말고 보고. `warn` 이 있으면 보고에 쓴다(`빈 띠 …px` = 위 괘선 ~ 아래 여백 사이 가장 큰 빈 높이가 260px 초과, 색 역할·밝기 보정, 큰 글자 서체 대체). `big.size` 가 2~5장 같고, 목록 시작 `gaps.rowsStart` 가 2~5장 같고, 2~4장 `gaps.rowsEnd` 가 `gaps.rowsBottom` 과 같아야(목록이 아래까지 채움) 한다. 장별 `contrast`(글자·큰 낱말·번호 대비)·`accent`(색 역할·블록·밝기 이동 폭)·`press`(농도 판 지표)·`side`(큰 낱말이 붙은 쪽)·`inkTexture`(잉크 면별 고주파·줄·점 측정값, 1080·540)가 남는다(`out/s<n>-inkmask.png` 는 검사용 마스크 — 결과물 아님).
2. `out/s1.png` ~ `out/s5.png` 를 **한 장씩 직접 열어 본다**(이미지 보기 도구). 확인: 브랜드·직무명·한 줄·큰 낱말·줄이 입력과 글자 하나까지 같은지(순서·띄어쓰기 포함 — 직무명의 줄바꿈은 띄어쓰기가 아니다), 2~4장에 브랜드명이 없는지, 2~5장 큰 낱말 크기·목록 시작 높이가 같은지, 항목이 2개인 장도 목록이 아래까지 차 있는지, 포인트색이 다섯 장에서 같은 색으로 보이는지(노랑이 올리브·겨자 글자로 바뀌지 않았는지), 1장 제목·큰 낱말·블록 안에 세로 줄무늬·흰 점·검은 점·얼룩이 없는지(원본과 540 축소본 모두), 2·4장 큰 낱말은 왼쪽·3·5장은 오른쪽에 붙고 블록이 붙은 쪽 화면 끝까지 나가는지, 띄어쓰기 없는 직무명이 한 줄인지, 5장 큰 낱말이 어두운 바탕에서 또렷한지, 1장 차례 줄이 02~05 와 2~5장 큰 낱말인지, 5장이 색면으로 뒤집혔고 맨 아래 브랜드가 있는지, 글자가 여백·괘선에 닿거나 오른쪽 끝이 잘리지 않았는지, 판에 원래 있던 글자·선·점이 없는지, 피드 크기(가로 540px)로 줄여서도 줄 글자가 읽히는지. 1장 사진을 썼으면 사진 위 글자가 또렷한지.
3. MP4 이면 `out/frames/` 의 장별 4장(0·0.7·1.4·5.36초)을 본다: 0초는 바탕만(괘선 없음), 0.7초는 큰 글자가 아래에서 올라오고 줄이 롤러로 찍히는 중, 1.4초부터 완성, 끝 프레임도 완성. 영상마다 5.4초, 1080×1350, 30fps.
4. 문제가 있으면 원인(판·글자 수·색·props)을 고쳐 다시 렌더한다.
## 8. 보고
결과 파일 경로(`out/s1.png`~`out/s5.png`, MP4 면 `out/s1.mp4`~`out/s5.mp4`), 배경판을 어떻게 얻었는지(생성·내 사진·예시 판·ChatGPT)·1장 사진을 썼는지와 check.json 의 장별 `plateMode`·`fill`·`big`(줄·크기)·`rows`(개수·크기)·`maxBlankPx`·`specks`·`specks540`·`texture`·`warn`, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. 인스타그램 캐러셀에는 PNG 5장을 순서대로 올린다. MP4 는 장마다 등장 1.4초(괘선 → 큰 글자가 올라옴 → 줄이 롤러로 찍힘) + 분위기 4초(빛 한 번 지나감)다.
폰트(전부 SIL OFL 1.1, 상업 사용 가능): 직무명·큰 낱말 Hahmlet 900(서체에 없는 음절이 있으면 Noto Sans KR 900), 한 줄·줄·브랜드 IBM Plex Sans KR 700, 번호 IBM Plex Mono 700.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Claude Code·Codex 같은 코딩 에이전트에 붙여 넣고 브랜드·색·주제·1장~5장 글자를 알려 주세요. 실제 폰트로 5장을 렌더해 글자가 깨지지 않아요.
- 올리기
PNG 5장(원하면 장마다 MP4)을 받아 인스타그램에서 여러 장 선택으로 순서대로 올리세요.
글자 분량
- 1장 · 표지
- 직무명 10자 · 한 줄 18자
- 2~4장 · 업무·조건·혜택
- 큰 낱말 4자 · 줄(1~4개) 18자
- 5장 · 지원
- 큰 낱말 4자 · 지원 방법 줄(1~3개) 32자
한도를 넘기면 ChatGPT가 그리지 않고 줄여 달라고 되묻습니다.
지킬 것
- 잘 맞는 글: 아르바이트·직원 모집, 직무 하나 채용 공고
- 급여·근무 시간·혜택·연락처는 실제 조건만 넣으세요. 2~4장에는 브랜드명을 넣지 않습니다(브랜드는 1장과 5장에만).
- 코딩 에이전트(Claude Code·Codex)가 실제 폰트로 렌더해 글자가 깨지지 않습니다. 사진은 쓰지 않고, 원하면 실제 작업장 사진 한 장을 1장 바탕으로만 씁니다.