← 이미지
이미지
채용·캠페인 포스터 · 흑백 사진과 초대형 단어
코드 렌더 · 같은 결과
흑백 사진 속 사람의 머리가 초대형 단어 하나를 가리는 잡지 표지 같은 포스터를 만드세요.
가상 브랜드 단단제작소로 그린 예시입니다. 도구와 시점에 따라 결과가 다릅니다.
레시피 보기60,503자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 채용·캠페인 포스터 (흑백 사진 + 초대형 단어)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델에게 한글을 그리게 하지 않는다. 글자 없는 사진 판 위에 실제 폰트로 글자를 코드로 얹고, 헤드리스 브라우저로 PNG(필요하면 MP4)를 렌더한다.
원본 템플릿: https://duchamp.app/readymade/images/poster-word-over-photo
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름)
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "사진 톤, 글자색, 포인트색" HEX 3개. 비우면 #111111, #F2F0EB, #FF5A1F
주제: (내 브랜드에 맞는 주제 하나) # 사진에 나올 일·장면. 예: 용접 엔지니어 채용
넣을 글자: (주제에 맞춰 분량 상한 안에서 문구를 지어 넣는다) # "큰 단어 / 한 줄". 큰 단어 2~3자(2자 권장), 한 줄 16자 이내
출력: PNG만 # PNG만 | MP4까지
배경판: 자동 # 자동 | 내 사진: <경로> | 예시 판 | ChatGPT 판: <경로>
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 글자 한도를 넘으면 줄여 달라고 한다. 브랜드명·넣을 글자는 한 글자도 바꾸지 않는다(띄어쓰기 포함). 채용 인원·조건 등 주지 않은 사실은 만들지 않는다.
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `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 tools/cutout.swift
public/plates/plate.(png|jpg) ← 글자 없는 배경판(인물 포함)
public/plates/person.(png|webp) ← 같은 판에서 인물만 남긴 투명 PNG/WebP(없으면 null)
out/still.png out/check.json (out/video.mp4 out/frames/*.png)
```
## 3. 배경판 고르기 (`배경판` 값에 따라 하나)
판 조건: 흑백 35mm 필름 사진, 4:5 세로, **글자·로고 없음**, 인물 한 명이 뒷모습/옆모습(얼굴 식별 불가), 낮은 앵글로 머리가 화면 **가로 가운데**에서 위 15~25% 높이까지 솟고(머리가 두 글자 사이 틈을 가리게), 머리 위쪽은 단순한 배경(단어가 들어갈 자리).
- **자동**: 이미지 생성 도구가 있으면(예: 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` 등) — 사용자 크레딧이 드니 쓰기 전에 몇 장 만들지 말하고 승인받는다. 판 대조·렌더 검사·다시 만들기 규칙은 어느 경로든 같고, 어느 경로로 판을 만들었는지 보고에 쓴다. 셋 다 없으면 `예시 판` 으로 넘어가고 보고에 그 사실을 쓴다.
- **내 사진: <경로>**: 그 파일을 `public/plates/plate.<확장자>` 로 복사. 컬러여도 코드가 흑백으로 바꾼다.
- **예시 판**: Duchamp 공개 예시 판(용접공 사진)을 받는다.
```bash
mkdir -p public/plates
curl -fsSL https://duchamp.app/readymade/recipe-plates/poster-word-over-photo/plate.jpg -o public/plates/plate.jpg
curl -fsSL https://duchamp.app/readymade/recipe-plates/poster-word-over-photo/person.webp -o public/plates/person.webp
```
이 경우 4단계(인물 분리)는 건너뛰고, props.json 의 `person` 을 `"plates/person.webp"`, `torch` 를 `[968, 1022]`(용접 불꽃 깜빡임)로 둔다.
- **ChatGPT 판: <경로>**: 사용자가 ChatGPT 에서 아래 프롬프트로 만든 이미지 파일. `내 사진` 과 같이 복사한다.
판 생성 프롬프트(이미지 도구에 그대로; `{주제}` 만 바꿔 넣는다):
```
Black and white 35mm film photograph, portrait 4:5. One person doing {주제}, seen from behind or in profile so the face cannot be recognised (safety helmet, cap or shadow is fine). Low-angle medium close-up: head and shoulders fill the lower 70% of the frame, the head is horizontally centred (the middle of the head within the central 10% of the frame width), and the top of the head rises to about 18% from the top edge. Above the head there is plain, slightly blurred background of the real location (no objects crossing the top 15% of the frame) — this space will hold a giant headline added later. Visible silver grain, deep blacks, slight halation on highlights, real fabric and tool detail, no illustration or CGI look. Absolutely NO text, letters, numbers, logos, signs or watermarks anywhere. Fully opaque, edge to edge.
```
## 4. 인물 분리 (예시 판이면 건너뜀)
단어가 인물 **뒤**에 오려면 같은 판에서 인물만 남긴 투명 PNG 가 필요하다.
1. macOS 14 이상: 설치 없이 `swift tools/cutout.swift public/plates/plate.<확장자> public/plates/person.png` (사진 앱 "피사체 들어올리기"와 같은 모델, 1~3초). `OK ...` 가 나오면 성공. 샌드박스 안에서는 `Could not build inference plan - ANECF error` 로 실패한다 → 렌더처럼 샌드박스 밖 실행 승인을 받아 다시 시도하고, 승인이 없으면 2로.
2. macOS 가 아니거나 1이 실패(`NO_SUBJECT` 등)하고 이미지 도구가 있으면 **인물 판을 따로 생성**: 이미지 도구에 `plate` 를 편집 입력으로 주고 "Keep the person exactly as is (same pose, size, position, framing). Replace everything except the person with flat pure green #00FF00. No shadows on the green, no text." → 결과를 `public/plates/person.png` 로 복사. 코드가 초록을 자동으로 빼서 투명하게 만든다.
3. 둘 다 안 되면 props.json 에서 `"person": null`. 단어가 인물 앞에 놓인다(겹침 없음). 보고에 쓴다.
## 5. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"word": "<큰 단어>",
"line": "<한 줄>",
"colors": {"tone": "<사진 톤>", "ink": "<글자색>", "accent": "<포인트색>"},
"plate": "plates/plate.<확장자>",
"person": "plates/person.png",
"torch": null,
"wordTop": null
}
```
- `wordTop` 은 비워 둔다(null). 코드가 인물 머리 위치를 재서 머리가 단어 아래 절반을 가리도록 단어 높이를 정하고, 글자(특히 받침)가 너무 가려지면 단어를 올리거나 조금 줄인다(글자마다 잉크 30%·받침 쪽 25% 이하만 가려지게). 그래도 안 되면 단어를 인물 앞에 둔다.
## 6. 코드 (그대로 저장)
`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/nanum-gothic-coding": "5.3.0",
"@fontsource-variable/noto-serif-kr": "5.3.0"
}
}
```
`render.mjs`
```js
// usage: node render.mjs png | mp4 → out/still.png (+ out/video.mp4), out/check.json
import fs from 'node:fs';
import path from 'node:path';
import {ff, meta, pick, render} from './hf.mjs';
const mode = process.argv[2] ?? 'png';
const inputProps = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.mkdirSync('out', {recursive: true});
const t0 = Date.now();
const m = await meta(inputProps);
// 완성 프레임 = 42(등장 끝, 분위기 위상 0) → 정지 이미지
const r = await render({name: 'still', props: inputProps, m, format: 'png', frames: [42], out: ['out/still.png']});
if (r.fail) { console.error('렌더 실패:', r.fail); process.exit(1); }
const check = pick(r.logs, 'DUCHAMP_CHECK').at(-1) ?? {ok: false, error: 'DUCHAMP_CHECK 없음'};
fs.writeFileSync('out/check.json', JSON.stringify(check, null, 2));
console.log('PNG out/still.png', `${((Date.now() - t0) / 1000).toFixed(1)}s`);
console.log('CHECK', JSON.stringify(check));
if (mode === 'mp4' && !check.ok) { console.error('검사 실패 → MP4 를 만들지 않음. out/check.json 의 hint 를 보고 props.json 을 고친 뒤 다시'); process.exit(2); }
if (mode === 'mp4') {
let last = -1;
const v = await render({name: 'video', props: inputProps, m, format: 'mp4', crf: 17, out: 'out/video.mp4',
onProgress: (p) => { const q = Math.floor(p * 10); if (q !== last) { last = q; console.log(`MP4 ${q * 10}%`); } }});
if (v.fail) { console.error('렌더 실패:', v.fail); process.exit(1); }
// 확인용 프레임(영상에서 직접 뽑음, ffmpeg-static) → out/frames/t*.png
fs.mkdirSync('out/frames', {recursive: true});
for (const t of ['0.00', '0.50', '1.00', '1.40', '3.40', '5.36']) {
ff(['-v', 'error', '-y', '-ss', t, '-i', path.resolve('out/video.mp4'), '-frames:v', '1', '-vf', 'scale=540:-2', path.resolve(`out/frames/t${t}.png`)]);
}
console.log('MP4 out/video.mp4', `${((Date.now() - t0) / 1000).toFixed(1)}s`, '(5.4s · 1080×1350 · 30fps · 등장 1.4s + 분위기 4s)', '· 프레임 확인 out/frames/');
}
process.exit(check.ok ? 0 : 2);
```
`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, useRef, useState} from 'react';
import {AbsoluteFill, Composition, Easing, Img, cancelRender, continueRender, delayRender, interpolate, registerRoot, staticFile, useCurrentFrame} from './duchamp-frame';
// ── Duchamp 코드 레시피 · poster-word-over-photo (C 짧게: 등장 1.4s + 분위기 4s 루프) ──
const W = 1080, H = 1350, FPS = 30, INTRO = 42, LOOP = 120;
type Props = {
brand: string; word: string; line: string;
colors: {tone: string; ink: string; accent: string};
plate: string; person: string | null;
torch: [number, number] | null; wordTop: number | null;
};
const defaults: Props = {
brand: '단단제작소', word: '용접', line: '용접 엔지니어 2명을 찾습니다',
colors: {tone: '#111111', ink: '#F2F0EB', accent: '#FF5A1F'},
plate: 'plates/plate.jpg', person: 'plates/person.png', torch: null, wordTop: null,
};
// 폰트 — Google Fonts(전부 SIL OFL 1.1). 못 불러오면 렌더를 실패시킨다(대체 글꼴로 조용히 그리지 않게).
const FONT_CSS = 'https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@900&family=Nanum+Gothic+Coding&display=block';
const loadFonts = async (p: Props) => {
await new Promise<void>((res, rej) => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = FONT_CSS;
l.onload = () => res(); l.onerror = () => rej(new Error('Google Fonts CSS 로드 실패'));
document.head.appendChild(l);
});
const specs: [string, string][] = [['900 100px "Noto Serif KR"', p.word], ['37px "Nanum Gothic Coding"', p.brand + p.line]];
for (const [s, t] of specs) {
await document.fonts.load(s, t);
if (!document.fonts.check(s, t)) throw new Error('폰트 없음: ' + s);
}
};
const loadImg = (src: string) => new Promise<HTMLImageElement>((res, rej) => {
const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('이미지 로드 실패: ' + src)); i.src = src;
});
// 인물 판 → (W×H, object-fit: cover 배치) 알파 + 이미지 URL. 투명 PNG 면 그대로, 불투명(초록 배경 #00FF00)이면 초록을 빼서 투명하게
const preparePerson = async (src: string) => {
const img = await loadImg(src);
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d', {willReadFrequently: true})!;
const s = Math.max(W / img.width, H / img.height);
g.drawImage(img, (W - img.width * s) / 2, (H - img.height * s) / 2, img.width * s, img.height * s);
const d = g.getImageData(0, 0, W, H);
let opaque = true;
for (let k = 3; k < d.data.length; k += 4 * 97) if (d.data[k] < 250) { opaque = false; break; }
if (opaque) {
for (let k = 0; k < d.data.length; k += 4) {
const [r, gg, b] = [d.data[k], d.data[k + 1], d.data[k + 2]];
const spill = gg - Math.max(r, b); // 초록이 얼마나 튀는지
d.data[k + 3] = Math.round(255 * Math.min(1, Math.max(0, 1 - (spill - 40) / 60)));
if (spill > 0) d.data[k + 1] = Math.max(r, b); // 가장자리 초록 번짐 제거
}
g.putImageData(d, 0, 0);
}
return {alpha: d.data, url: opaque ? c.toDataURL('image/png') : src, keyed: opaque};
};
const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
const ramp = (f: number, a: number, b: number) => interpolate(f, [a, b], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: EASE_OUT});
const enter = (f: number, s: number, len: number) => 1 - ramp(f, s, s + len); // 1=숨음 → 0=제자리
const T = (x: number) => (x * INTRO) / 75;
const breathe = (f: number) => (1 - Math.cos((2 * Math.PI * f) / LOOP)) / 2;
const wave = (f: number, k: number, a: number, ph: number) => a * Math.sin((2 * Math.PI * k * f) / LOOP + ph);
const Grain: React.FC<{opacity: number}> = ({opacity}) => {
const f = useCurrentFrame();
return (
<svg width={W} height={H} style={{position: 'absolute', inset: 0, opacity, mixBlendMode: 'overlay', pointerEvents: 'none'}}>
<filter id={`g${f}`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency={0.85} numOctaves={2} seed={(f * 7919) % 997} stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter={`url(#g${f})`} />
</svg>
);
};
const Poster: React.FC<Props> = (p) => {
const [handle] = useState(() => delayRender('fonts+layout'));
const measure = useRef<HTMLDivElement>(null);
const [lay, setLay] = useState({size: 482, top: 18, front: false, ready: false, personUrl: ''});
const M = 40; // 좌우 여백
useEffect(() => {
(async () => {
await loadFonts(p);
// ① 큰 단어: 좌우 여백 안에 꽉 차게(최대 482px). 글자 폭은 100px 기준으로 잰다
const w100 = [...measure.current!.children].map((c) => c.getBoundingClientRect().width);
const fullSize = Math.min(482, ((W - 2 * M) / w100.reduce((s, x) => s + x, 0)) * 100 * 0.98);
const person = p.person ? await preparePerson(staticFile(p.person)) : null;
const a = person ? person.alpha : null;
// ② 크기 size 일 때 글자 잉크(캔버스에 같은 폰트로 그림) 중 인물에 가려지는 비율: 전체 ink, 아래 40%(받침 쪽) low
const coverAt = (size: number) => {
const ws = w100.map((x) => (x * size) / 100);
const xs = ws.length <= 2 ? [M, W - M - ws[ws.length - 1]].slice(0, ws.length) : ws.map((_, i) => (W - ws.reduce((s, x) => s + x, 0)) / 2 + ws.slice(0, i).reduce((s, x) => s + x, 0));
const LH = Math.ceil(size);
const g = document.createElement('canvas').getContext('2d', {willReadFrequently: true})!;
g.canvas.width = W; g.canvas.height = LH;
g.font = `900 ${size}px "Noto Serif KR"`;
const fm = g.measureText(p.word);
const base = (size - (fm.fontBoundingBoxAscent + fm.fontBoundingBoxDescent)) / 2 + fm.fontBoundingBoxAscent; // CSS 줄 상자와 같은 기준선
[...p.word].forEach((c, i) => g.fillText(c, xs[i], base));
const inkData = g.getImageData(0, 0, W, LH).data;
const inks = ws.map((w, i) => {
const pts: [number, number][] = [];
for (let y = 0; y < LH; y += 2) for (let x = Math.round(xs[i]); x < Math.min(W, xs[i] + w); x += 2) if (inkData[(y * W + x) * 4 + 3] > 128) pts.push([x, y]);
const ys = pts.map((q) => q[1]); const y0 = Math.min(...ys), y1 = Math.max(...ys);
return {pts, lowY: y0 + (y1 - y0) * 0.6};
});
return (top: number) => inks.map(({pts, lowY}) => {
let hit = 0, lowHit = 0, low = 0;
for (const [x, y] of pts) {
const yy = Math.round(top + y); const on = !!a && yy < H && a[(yy * W + x) * 4 + 3] > 128;
if (on) hit++;
if (y >= lowY) { low++; if (on) lowHit++; }
}
return {ink: Math.round((hit / Math.max(1, pts.length)) * 100) / 100, low: Math.round((lowHit / Math.max(1, low)) * 100) / 100};
});
};
// 통과 기준: 글자마다 잉크 30% 이하, 받침 쪽(아래 40%) 25% 이하만 가려짐. ≤1 이면 통과
const badOf = (cov: {ink: number; low: number}[]) => Math.max(...cov.map((c) => Math.max(c.ink / 0.3, c.low / 0.25)));
// ③ 위치: 머리 꼭대기가 단어 줄 높이의 44% 지점(머리가 단어 아래 절반을 가림)에서 시작.
// 너무 가리면 단어를 위로 올리고(최소 12px), 그래도 안 되면 단어를 조금씩 줄여(최대 76%까지) 다시 찾는다
let headTop: number | null = null;
for (let y = 0; a && y < H && headTop === null; y++) {
let n = 0;
for (let x = Math.round(W * 0.15); x < W * 0.85; x++) if (a[(y * W + x) * 4 + 3] > 128) n++;
if (n > 12) headTop = y;
}
const startTop = (s: number) => Math.round(Math.min(H * 0.14, Math.max(12, headTop === null ? H * 0.06 : headTop - s * 0.44)));
let size = fullSize, top = p.wordTop ?? startTop(fullSize), coverOf = coverAt(size), found = !a;
if (p.wordTop === null && a) {
search: for (const k of [1, 0.94, 0.88, 0.82, 0.76]) {
const cov = coverAt(fullSize * k);
for (let t = startTop(fullSize * k); t >= 12; t -= 6) {
if (badOf(cov(t)) <= 1) { size = fullSize * k; top = t; coverOf = cov; found = true; break search; }
}
}
if (!found) { size = fullSize; top = startTop(fullSize); coverOf = coverAt(size); }
} else if (a) found = badOf(coverOf(top)) <= 1;
const front = !!a && !found; // 어떻게 해도 너무 가리면: 단어를 인물 앞에 둔다(겹침 효과는 포기, 판독 우선)
setLay({size, top, front, ready: true, personUrl: person ? person.url : ''});
// ④ 검사 결과 → 콘솔 DUCHAMP_CHECK 한 줄 (render.mjs 가 out/check.json 으로 저장)
requestAnimationFrame(() => {
const c = coverOf(top);
const overflow = [...document.querySelectorAll<HTMLElement>('[data-fit]')].filter((el) => el.scrollWidth > el.clientWidth + 1).map((el) => el.dataset.fit);
const report = {wordSize: Math.round(size), wordTop: top, headTop, cover: c, layered: !!a && !front, personKeyed: !!person?.keyed, overflow, ok: overflow.length === 0,
hint: front ? '머리가 글자(특히 받침)를 너무 가려서 단어를 인물 앞에 두었다(겹침 없음). 겹침을 원하면 큰 단어를 2글자로 줄이거나 머리가 화면 가운데가 아닌 판을 쓰기' : overflow.length ? '줄 넘침 → 글자 수를 줄이기' : null};
console.log('DUCHAMP_CHECK ' + JSON.stringify(report));
continueRender(handle);
});
})().catch((e) => cancelRender(e));
}, []);
const frame = useCurrentFrame();
const f = frame - INTRO; // 분위기 위상(loopStart=42 에서 0)
const amp = Math.sqrt(LOOP / 240);
const b = breathe(f);
const drift = wave(f, 1, 6 * amp, 0.4);
const origin = '45% 95%';
const layer = (s: number, dx: number): React.CSSProperties => ({position: 'absolute', inset: 0, transformOrigin: origin, transform: `translateX(${dx}px) scale(${1 + s * amp * b})`});
const chars = [...p.word];
const wordH = lay.size;
const flare = Math.max(0, wave(f, 1, 0.35, 0.3) + wave(f, 1, 0.25, 1.7) + wave(f, 2, 0.2, 2.9) + wave(f, 2, 0.2, 0.8)) * 0.9;
const photo: React.CSSProperties = {width: W, height: H, objectFit: 'cover', filter: 'grayscale(1)'};
return (
<AbsoluteFill style={{backgroundColor: p.colors.tone, overflow: 'hidden', opacity: lay.ready ? 1 : 0}}>
<div ref={measure} style={{position: 'absolute', visibility: 'hidden', display: 'flex', fontFamily: 'Noto Serif KR', fontWeight: 900, fontSize: 100}}>{[...p.word].map((c, i) => <span key={i}>{c}</span>)}</div>
<div style={layer(0.02, drift * 0.5)}><Img src={staticFile(p.plate)} style={photo} /></div>
{lay.personUrl && lay.front && <div style={layer(0.044, drift)}><Img src={lay.personUrl} style={photo} /></div>}
<div style={layer(0.032, drift * 0.8)}>
<div style={{position: 'absolute', left: M, right: M, top: lay.top, height: wordH, display: 'flex', justifyContent: chars.length <= 2 ? 'space-between' : 'center', overflow: 'hidden'}}>
{chars.map((c, i) => (
<span key={i} data-ch={i} style={{fontFamily: 'Noto Serif KR', fontWeight: 900, fontSize: lay.size, lineHeight: `${wordH}px`, color: p.colors.ink, display: 'block',
transform: `translateY(${enter(frame, T(8 + i * 7), T(40)) * wordH * 1.04}px)`, textShadow: '0 0 1px rgba(242,239,232,0.35)'}}>{c}</span>
))}
</div>
</div>
{lay.personUrl && !lay.front && <div style={layer(0.044, drift)}><Img src={lay.personUrl} style={photo} /></div>}
{p.torch && (
<div style={{position: 'absolute', left: p.torch[0] - 260, top: p.torch[1] - 260, width: 520, height: 520, borderRadius: '50%', mixBlendMode: 'screen', opacity: flare,
background: 'radial-gradient(circle, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.16) 22%, rgba(255,255,255,0) 62%)'}} />
)}
<div style={{position: 'absolute', left: M, right: M, bottom: 52, fontFamily: 'Nanum Gothic Coding', fontSize: 37, lineHeight: '52px'}}>
{[{t: p.brand, c: p.colors.accent, k: 'brand'}, {t: p.line, c: p.colors.ink, k: 'line'}].map((r, i) => (
<div key={i} data-fit={r.k} style={{overflow: 'hidden', height: 52}}>
<div style={{color: r.c, transform: `translateY(${enter(frame, T(34 + i * 6), T(30)) * 54}px)`, whiteSpace: 'pre', textShadow: '0 1px 6px rgba(0,0,0,0.45)'}}>{r.t}</div>
</div>
))}
</div>
<Grain opacity={0.1} />
</AbsoluteFill>
);
};
registerRoot(() => (
<Composition id="Main" component={Poster} 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));
};
```
`tools/cutout.swift`
```swift
// usage: swift tools/cutout.swift <판.png|jpg> <인물.png> (macOS 14+, 설치 없음)
// 사진 앱 "피사체 들어올리기"와 같은 Vision 모델로 인물만 남긴 투명 PNG(원본과 같은 크기)를 만든다.
import Foundation
import Vision
import CoreImage
let a = CommandLine.arguments
guard a.count == 3, let img = CIImage(contentsOf: URL(fileURLWithPath: a[1])) else { print("usage: swift cutout.swift in out.png"); exit(1) }
let h = VNImageRequestHandler(ciImage: img)
let r = VNGenerateForegroundInstanceMaskRequest()
try h.perform([r])
guard let o = r.results?.first, !o.allInstances.isEmpty else { print("NO_SUBJECT"); exit(2) }
let buf = try o.generateMaskedImage(ofInstances: o.allInstances, from: h, croppedToInstancesExtent: false)
let out = CIImage(cvPixelBuffer: buf)
try CIContext().writePNGRepresentation(of: out, to: URL(fileURLWithPath: a[2]), format: .RGBA8, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!)
print("OK", Int(out.extent.width), "x", Int(out.extent.height), "instances:", o.allInstances.count)
```
## 7. 실행
```bash
npm install --no-audit --no-fund
node render.mjs png # 출력: PNG만
node render.mjs mp4 # 출력: MP4까지 (PNG 도 함께 만든다)
```
- 폰트 패키지(`npm install` 로 받은 것)나 판 이미지를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
- 종료 코드 2 = 검사 실패. `out/check.json` 의 `hint` 를 읽고 props.json(글자 수)을 고친 뒤 다시 실행.
## 8. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: `ok: true`, `overflow: []`. `layered: false` 면 단어가 인물 앞에 놓였다는 뜻 → 이유(`hint`)를 보고.
2. `out/still.png` 를 **직접 열어 본다**(이미지 보기 도구). 확인: 큰 단어·한 줄·브랜드명이 입력과 글자 하나까지 같은지, 큰 단어가 좌우 여백 안에 있는지, 인물 머리가 단어 아래쪽을 가리되 모든 글자(특히 받침)가 읽히는지, 판에 원래 있던 글자·로고·워터마크가 없는지.
3. MP4 이면 `out/frames/` 의 6장(0·0.5·1.0·1.4·3.4·5.36초)을 본다: 0초는 단어 없이 사진만, 1.4초부터 완성 포스터, 끝 프레임도 완성 포스터. 영상 길이 5.4초, 1080×1350, 30fps.
4. 문제가 있으면 원인(판·글자 수·props)을 고쳐 다시 렌더한다. 판에 글자가 남았으면 판을 다시 만든다.
## 9. 보고
결과 파일 경로(`out/still.png`, `out/video.mp4`), 배경판을 어떻게 얻었는지(생성·내 사진·예시 판·ChatGPT), 인물 분리 방식, check.json 요약, 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜. MP4 는 등장 1.4초 + 분위기 4초다. 웹에서 반복 재생할 때는 1.4초 지점으로 되감으면 등장이 한 번만 나온다(인스타그램 등에 그대로 올리면 처음부터 반복).
폰트(전부 SIL OFL 1.1, 상업 사용 가능): 큰 단어 Noto Serif KR Black, 아래 두 줄 Nanum Gothic Coding.
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Codex·Claude Code 같은 코딩 에이전트에 붙여 넣으세요. ChatGPT로 그리려면 ▾에서 ChatGPT 이미지 프롬프트를 고르세요.
빈칸 채우는 법
- [브랜드명]
- 손님이 부르는 이름 그대로 적으세요. 예: 수선공방 바늘
- [브랜드 색]
- [브랜드 색 첫 번째]·[두 번째]·[세 번째] 자리에 HEX를 하나씩 넣으세요. 첫 번째는 바탕, 두 번째는 글자, 세 번째는 포인트입니다. 브랜드 색이 없으면 대괄호째 지우세요. 기본색(#F4F1EA · #1C1C1C · #C8553D)으로 그립니다.
- [주제]
- 이 이미지가 보여 줄 장면이나 대상을 한 줄로 적으세요. 확인된 항목에서 고르면 됩니다. 예: 아침 첫 판을 굽는 시간
- [넣을 글자]
- 이미지에 그릴 한글을 그대로 적으세요. [넣을 글자의 제목]·[넣을 글자의 보조]처럼 나뉜 자리에는 그 역할의 문구를 하나씩 넣습니다. 분량을 넘기면 ChatGPT가 줄여 달라고 되묻습니다.
이 템플릿에 나오는 빈칸입니다. 대괄호가 남아 있으면 ChatGPT가 그리지 않고 먼저 되묻습니다.
지킬 것
- 올리기 전에 1080×1350으로 자르세요.
- 사람은 뒷모습이나 옆모습이라 얼굴이 보이지 않습니다. 연출한 사진이니 실제 직원·현장 사진처럼 소개하지 마세요.
- 채용 인원·조건은 내가 정한 것만 씁니다.

