Deck · Pitch deck
에디토리얼 다크 피치덱
코드 렌더 · 같은 결과
짙은 바탕의 잡지식 그리드와 명조 제목. 내 레포의 사실만으로 투자자용 피치덱을 코드로 만듭니다.
위에 마스트헤드(브랜드 · 쪽수 + 가는 선), 왼쪽 칸에 장 이름과 큰 장 번호, 오른쪽 넓은 칸에 명조 제목과 가는 선으로 나눈 받침 줄. 브랜드 색은 한 점 — 장 표시 점과 큰 장 번호에만. 참고 감각: Stripe Sessions, a16z 리서치 자료.
장별 PNG(1920×1080) · deck.pdf · 모아 보기(contact.png). 이미지 모델·배경 사진 없이 도형과 활자만으로 그립니다.
빈 칸은 에이전트가 레포를 읽어 초안을 만들고 확인받은 뒤 렌더합니다. 숫자 장(시장·견인·금액)은 숫자를 줄 때만 생깁니다.

예시 회사(가상) · 수치는 예시 수치 — 실제 회사·실적이 아닙니다. 예시 회사 · 제품 화면은 코드로 그린 목업입니다.
예시 PDF 받기 (10쪽)예시 · Fernway — Route planning for regional freight carriers. 예시는 가상 회사로 레시피를 실제로 실행한 결과입니다. 같은 입력이면 같은 장이 나옵니다.
레시피 보기94,523자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 에디토리얼 다크 피치덱 (짙은 바탕 · 잡지 그리드)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델·배경 사진을 쓰지 않는다. 덱 사실표(YAML)를 `props.json` 으로 옮기면 React 컴포지션 코드가 실제 폰트로 장마다 1920×1080 PNG 를 그리고(HyperFrames, Apache 2.0 — 헤드리스 Chrome), PNG 를 `pdf-lib` 로 묶어 `deck.pdf` 를 만든다. 레이아웃·글자 크기·줄바꿈은 코드가 재서 정하고, 글자가 넘치면 줄이지 않고 **몇 자를 줄여야 하는지** 보고한다.
짙은 바탕의 잡지식 그리드. 위에 마스트헤드(브랜드 · 쪽수 + 가는 선), 왼쪽 칸에 장 이름과 큰 장 번호, 오른쪽 넓은 칸에 명조 제목과 가는 선으로 나눈 받침 줄. 브랜드 색은 한 점 — 장 표시 점과 큰 장 번호에만 쓰고, 숫자는 흰 명조로 크게. 참고 감각: Stripe Sessions, a16z 리서치 자료.
원본 템플릿: https://duchamp.app/readymade/decks/deck-editorial-dark
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름) # 덱 위 줄·표지에 그대로. 한 글자도 바꾸지 않는다
브랜드 색: (내 브랜드 색 — 주 색, 바탕 색 HEX 2개) # "주 색, 바탕 색" HEX 2개. 비우면 #FF6A3D, #0E0F11. 바탕 색이 밝으면(밝기 0.03 초과) 기본 짙은 바탕을 쓰고 경고한다. 주 색이 짙은 바탕과 4.5:1 미만이면 같은 색상의 밝은 틴트로 올려 쓴다(경고)
주제: (무엇을 누구에게 — 한 문장. 표지의 큰 줄) # 무엇을 누구에게 — 한 문장. 표지의 큰 줄로 그대로 쓴다(예: Route planning for regional freight carriers)
넣을 글자: (아래 덱 사실표를 레포·대화에서 확인한 사실로 초안을 채워 나에게 확인받는다 — 모르는 칸은 비운다. 숫자는 내가 준 것만) # 아래 '덱 사실표'를 채운다. 모르는 칸은 비운다
로고: 없음 # 없음 | <SVG·PNG 경로> — Duchamp 로고 키트의 out/svg/lockup-horizontal.svg 를 그대로 받는다(옆에 -dark.svg 가 있으면 어두운 바탕에서 그것을 쓴다)
스크린샷: 없음 # 없음 | <PNG·JPG 경로 1~2개> — 사용자의 실제 제품 화면. 없으면 제품 장은 기능 칸으로 그린다
```
덱 사실표(`넣을 글자`). 장 하나에 메시지 하나 — `한 줄` 이 그 장의 메시지다. 비운 장은 코드가 뺀다.
```yaml
문제:
한 줄: "" # 필수. 이 장의 메시지 한 문장
근거: ["", "", ""] # 0~3개. 각 영문 90자·한글 45자 안팎
해결:
한 줄: "" # 필수
설명: "" # 한두 문장
제품:
한 줄: "" # 비우면 고정 문구("What <브랜드> does." / "<브랜드>의 핵심 기능")
기능: # 0~3개. 이름과 설명이 있으면 스크린샷이 없어도 제품 장을 그린다
- {이름: "", 설명: ""}
왜 지금:
한 줄: "" # 비우면 장을 뺀다
근거: []
시장: # 사용자가 준 숫자만. 값이 하나도 없으면 장을 뺀다
한 줄: "" # 비우면 첫 값(TAM)이 큰 숫자로 이 장의 주인공
TAM: {값: "", 설명: ""} # 값마다 무엇의 크기인지(설명) 필수
SAM: {값: "", 설명: ""}
SOM: {값: "", 설명: ""}
출처: ""
사업 모델:
한 줄: "" # 한 줄·가격이 둘 다 비면 장을 뺀다
가격: "" # 예: $39 · 월 9,900원
단위: "" # 예: per truck, per month
근거: [] # 0~3개
견인: # 사용자가 준 숫자만. 지표·추이가 다 비면 장을 뺀다
한 줄: ""
지표: [{값: "", 설명: ""}] # 0~3개. 값마다 설명 필수
추이: {이름: "", 값: []} # 선택. 이름 = 무슨 숫자·단위(예: Monthly recurring revenue ($K)), 값 = [["Apr", 6], ["May", 9], …] 2~12개 → 막대 그래프(막대 위에 숫자)
팀:
한 줄: "" # 비우면 고정 문구("The people building <브랜드>." / "<브랜드>를 만드는 사람들")
사람: [{이름: "", 역할: "", 한 줄: ""}] # 0~4명. 비우면 장을 뺀다
요청:
금액: "" # 비우면 장은 남기고 "함께할 분을 찾습니다." 와 연락처만
라운드: "" # 예: Seed · 시드
용도: [{항목: "", 비율: 0}] # 0~5개, 비율 합 100. 금액이 있을 때만 그린다
연락처: {이름: "", 이메일: "", 웹: ""}
```
## 1. 에이전트 규칙
- 위 `[ ]` 칸(브랜드명·주제)이 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다.
- **덱 사실표가 비어 있으면**: 지금 폴더(사용자의 제품 레포)를 읽어 초안을 채운다 — `README*`, `package.json`(name·description), 랜딩 페이지 문구(`app/`·`pages/`·`src/` 의 첫 화면 컴포넌트, `public/*.html`), `docs/`. 초안은 문제·해결·제품 기능·팀처럼 레포에 근거가 있는 칸만 채우고 칸마다 근거 파일을 한 줄 단다. 초안을 사용자에게 보여 주고 **확인을 받은 뒤에만** 렌더한다. 사실표가 사용자가 채운(확인한) 상태로 주어졌으면 묻지 말고 끝까지 진행한다.
- **견인·시장·금액·가격 숫자는 사용자가 준 것만 쓴다.** 레포에서 숫자를 찾았어도(예: 가격표 페이지) 사용자 확인 전에는 넣지 않는다. 비어 있으면 `[확인 필요]` 같은 자리표시를 넣지 말고 비워 둔다 — 코드가 그 장을 뺀다(요청 장만 금액 없이 남는다).
- 지어낸 고객 로고·후기·인용·차트를 넣지 않는다. 스크린샷은 사용자가 준 실제 제품 화면만 쓴다(만들거나 꾸미지 않는다).
- 사용자가 준 문장·숫자·이름은 한 글자도 바꾸지 않는다(번역·요약·다듬기 금지). 에이전트가 쓴 초안 문장은 사용자가 확인한 그대로 쓴다.
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.json` 하나뿐이다. 코드가 실패하면 오류 메시지를 그대로 보고하고, 고쳐야 했다면 무엇을 왜 고쳤는지 보고에 쓴다.
- 이미지 생성 모델·유료 API 를 쓰지 않는다. 비밀 값·.env 파일은 읽지 않는다. 이 작업 밖의 일(프로젝트 관리 카드·메모 갱신, 배포, 다른 브라우저 자동화 도구)은 하지 않는다.
- 필요한 것: Node 18 이상, 인터넷(`npm install` 때만 — 폰트도 npm 패키지와 duchamp.app 자가 호스팅 파일로 설치 때 받으므로 렌더는 인터넷 없이 된다). 설치는 이 폴더 안에서만. 첫 렌더 때 HyperFrames 가 헤드리스 Chrome(약 190MB)을 이 폴더의 `.render/home` 에 받는다. ffmpeg 은 npm 패키지(ffmpeg-static)로 따라온다.
- **샌드박스 안에서 실행 중이면(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`)을 보고한다.
- `npm install` 이 `EPERM`(홈의 npm 캐시 권한)으로 실패하면 `npm install --no-audit --no-fund --cache .npm-cache` 로 이 폴더 안 캐시를 쓴다.
- 사용자의 제품 레포 안에서 실행한다면 덱 파일은 `deck/` 같은 하위 폴더를 만들어 그 안에 둔다(레포의 package.json 을 건드리지 않는다).
## 2. 파일 구조 (덱 폴더에 만든다)
```
package.json render.mjs hf.mjs fonts.json props.json src/index.tsx src/deck.tsx src/duchamp-frame.tsx
(로고·스크린샷 파일 — props.json 에 적은 경로. render.mjs 가 public/assets/ 로 복사한다)
out/slides/NN-<장>.png (1920×1080) out/deck.pdf out/contact.png (전체 장 모아 보기) out/check.json
```
## 3. props.json (사실표 → 값)
```json
{
"brand": "<브랜드명 그대로>",
"topic": "<주제 그대로>",
"colors": {"primary": "<주 색 HEX 또는 \"\">", "background": "<바탕 색 HEX 또는 \"\">"},
"logo": "<로고 파일 경로 또는 \"\">",
"screenshots": ["<스크린샷 경로>"],
"facts": {
"problem": {"headline": "<문제.한 줄>", "points": ["<문제.근거>"]},
"solution": {"headline": "<해결.한 줄>", "body": "<해결.설명>"},
"product": {"headline": "<제품.한 줄>", "features": [{"name": "<이름>", "detail": "<설명>"}]},
"whyNow": {"headline": "<왜 지금.한 줄>", "points": ["<근거>"]},
"market": {"headline": "<시장.한 줄>", "tam": {"value": "<값>", "label": "<설명>"}, "sam": {"value": "", "label": ""}, "som": {"value": "", "label": ""}, "source": "<출처>"},
"model": {"headline": "<사업 모델.한 줄>", "price": "<가격>", "unit": "<단위>", "points": ["<근거>"]},
"traction": {"headline": "<견인.한 줄>", "metrics": [{"value": "<값>", "label": "<설명>"}], "series": {"label": "<추이.이름>", "points": [["Apr", 6], ["May", 9]]}},
"teamHeadline": "<팀.한 줄>",
"team": [{"name": "<이름>", "role": "<역할>", "line": "<한 줄>"}],
"ask": {"amount": "<금액>", "round": "<라운드>", "use": [{"label": "<항목>", "percent": 50}], "contact": {"name": "", "email": "", "web": ""}}
}
}
```
- 빈 칸은 `""`·`[]` 로 둔다(지우지 않아도 된다). 추이 값은 숫자(따옴표 없이)다.
- 언어는 코드가 정한다: 글자 중에 한글이 있으면 한국어 덱(장 이름·고정 문구가 한국어, 줄바꿈은 낱말 단위), 없으면 영어 덱.
## 4. 코드 (그대로 저장)
`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",
"pdf-lib": "1.17.1",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource-variable/noto-serif-kr": "5.3.0"
},
"scripts": {
"postinstall": "node hf.mjs fonts"
}
}
```
`render.mjs`
```js
// usage: node render.mjs → out/slides/NN-<장>.png (1920×1080) · out/deck.pdf · out/contact.png · out/check.json
// 종료 코드 0 = 성공, 1 = 입력·렌더 오류, 2 = 자동 검사 실패(그래도 PNG·PDF 는 만든다 — 보고 고치라고)
import fs from 'node:fs';
import path from 'node:path';
import {PDFDocument} from 'pdf-lib';
import {ff, meta, pick, render} from './hf.mjs';
const t0 = Date.now();
const props = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.rmSync('out', {recursive: true, force: true});
fs.mkdirSync('out/slides', {recursive: true});
// ── 로고·스크린샷 → public/assets/ (렌더 페이지는 public/ 안 파일만 읽는다) + 픽셀 크기 ──
const size = (file) => {
const b = fs.readFileSync(file);
if (b.readUInt32BE(0) === 0x89504e47) return {w: b.readUInt32BE(16), h: b.readUInt32BE(20)};
if (b[0] === 0xff && b[1] === 0xd8) {
for (let i = 2; i < b.length - 9;) {
if (b[i] !== 0xff) { i++; continue; }
const m = b[i + 1], len = b.readUInt16BE(i + 2);
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return {w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5)};
i += 2 + len;
}
}
const s = b.toString('utf8', 0, Math.min(b.length, 4096));
if (/<svg/i.test(s)) {
const vb = /viewBox="\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(s);
const w = /<svg[^>]*\swidth="([\d.]+)(px)?"/.exec(s), h = /<svg[^>]*\sheight="([\d.]+)(px)?"/.exec(s);
if (w && h) return {w: +w[1], h: +h[1]};
if (vb) return {w: +vb[1], h: +vb[2]};
}
throw new Error(`그림 크기를 못 읽음(PNG·JPG·SVG 만): ${file}`);
};
const assets = {logo: null, logoDark: null, shots: []};
fs.rmSync('public/assets', {recursive: true, force: true});
const put = (file, name) => {
if (!fs.existsSync(file)) throw new Error(`파일 없음: ${file}`);
fs.mkdirSync('public/assets', {recursive: true});
const dst = `assets/${name}${path.extname(file).toLowerCase()}`;
fs.copyFileSync(file, path.join('public', dst));
return {src: dst, ...size(file)};
};
try {
if (props.logo && props.logo !== '없음') {
assets.logo = put(props.logo, 'logo');
// Duchamp 로고 키트: lockup-horizontal.svg 옆에 -dark.svg 가 있으면 어두운 바탕에서 그것을 쓴다
const dark = props.logo.replace(/(\.svg)$/i, '-dark$1');
if (dark !== props.logo && fs.existsSync(dark)) assets.logoDark = put(dark, 'logo-dark');
}
(Array.isArray(props.screenshots) ? props.screenshots : []).slice(0, 2).forEach((f, i) => assets.shots.push(put(f, `shot-${i + 1}`)));
} catch (e) { console.error('입력 오류:', e.message); process.exit(1); }
const input = {...props, assets};
const m = await meta(input);
const info = globalThis.__DUCHAMP_DECK;
if (info.errors.length) {
fs.writeFileSync('out/check.json', JSON.stringify({ok: false, input: info.errors, warnings: info.warnings}, null, 2));
console.error('입력 오류 → props.json 을 고친 뒤 다시:\n- ' + info.errors.join('\n- '));
process.exit(1);
}
const n = info.slides.length;
const files = info.slides.map((k, i) => `out/slides/${String(i + 1).padStart(2, '0')}-${k}.png`);
const r = await render({name: 'slides', props: input, m, format: 'png', frames: info.slides.map((_, i) => i), out: files});
if (r.fail) { console.error('렌더 실패:', r.fail); process.exit(1); }
const check = pick(r.logs, 'DUCHAMP_CHECK').at(-1) ?? {ok: false, failures: ['DUCHAMP_CHECK 없음']};
const tRender = (Date.now() - t0) / 1000;
// ── PDF: 장 PNG 를 16:9 페이지(960×540pt, 2배 밀도)로 묶는다. 날짜를 고정해 같은 입력이면 같은 파일 ──
const pdf = await PDFDocument.create();
pdf.setTitle(`${props.brand} — ${info.lang === 'ko' ? '소개서' : 'Pitch deck'}`); pdf.setAuthor(props.brand); pdf.setCreator('Duchamp deck recipe'); pdf.setProducer('pdf-lib');
pdf.setCreationDate(new Date(0)); pdf.setModificationDate(new Date(0));
for (const f of files) {
const img = await pdf.embedPng(fs.readFileSync(f));
pdf.addPage([960, 540]).drawImage(img, {x: 0, y: 0, width: 960, height: 540});
}
fs.writeFileSync('out/deck.pdf', await pdf.save());
const pages = (await PDFDocument.load(fs.readFileSync('out/deck.pdf'))).getPageCount();
const pdfBytes = fs.statSync('out/deck.pdf').size;
// ── contact.png: 전체 장을 한 장에(판정·검토용) ──
const tmp = path.resolve('.render/contact'); fs.rmSync(tmp, {recursive: true, force: true}); fs.mkdirSync(tmp, {recursive: true});
files.forEach((f, i) => fs.copyFileSync(f, path.join(tmp, `${String(i + 1).padStart(2, '0')}.png`)));
const cols = n <= 6 ? 3 : 4, rows = Math.ceil(n / cols);
const c = ff(['-v', 'error', '-y', '-framerate', '1', '-i', path.join(tmp, '%02d.png'), '-vf', `scale=600:-1:flags=lanczos,tile=${cols}x${rows}:padding=24:margin=24:color=0xE6E6E8`, '-frames:v', '1', path.resolve('out/contact.png')]);
if (!fs.existsSync('out/contact.png')) { console.error('contact.png 실패:', c.stderr); process.exit(1); }
// ── 파일 검사 + 화면 검사 합치기 ──
const failures = [...(check.failures ?? [])];
for (const f of files) { const s = size(f); if (s.w !== 1920 || s.h !== 1080) failures.push(`${f}: ${s.w}×${s.h} (1920×1080 이어야 함)`); }
if (pages !== n) failures.push(`PDF ${pages}쪽 ≠ 장 ${n}개`);
if (pdfBytes > 15 * 1024 * 1024) failures.push(`PDF ${(pdfBytes / 1048576).toFixed(1)}MB > 15MB — 스크린샷을 줄여서(가로 2400px 이하) 다시`);
const out = {
ok: failures.length === 0, style: check.style, lang: info.lang,
slides: info.slides.map((k, i) => ({n: i + 1, kind: k, file: files[i]})),
failures, warnings: check.warnings ?? info.warnings,
pdf: {file: 'out/deck.pdf', pages, bytes: pdfBytes}, contact: 'out/contact.png',
seconds: {render: +tRender.toFixed(1), total: +((Date.now() - t0) / 1000).toFixed(1)},
detail: check.detail,
};
fs.writeFileSync('out/check.json', JSON.stringify(out, null, 2));
console.log(`장 ${n}개 (${info.slides.join(' · ')}) PDF ${pages}쪽 ${(pdfBytes / 1024).toFixed(0)}KB ${out.seconds.total}s`);
if (out.warnings.length) console.log('경고:\n- ' + out.warnings.join('\n- '));
if (!out.ok) { console.error('검사 실패 → out/check.json 의 failures 를 보고 props.json 의 그 칸을 고친 뒤 다시:\n- ' + failures.join('\n- ')); process.exit(2); }
console.log('CHECK ok · out/slides/ · out/deck.pdf · out/contact.png');
```
`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
// ── deck-editorial-dark · 짙은 바탕, 잡지식 그리드(위 마스트헤드 + 왼쪽 칸 장 번호 · 오른쪽 넓은 칸 본문), 명조 제목, 브랜드 색은 한 점(장 표시 점과 큰 장 번호) ──
import React from 'react';
import {DEFAULT_HEAD, Deck, H, LABEL, OPEN_ASK, Pic, Slide, T, O, Unit, W, deck, fit, glue, hex, isHex, lum, mix, pad2, ratio} from './deck';
const SANS = "'Pretendard Variable', sans-serif", SERIF = "'Noto Serif KR', serif", MONO = "'IBM Plex Mono', monospace";
const X = 120, TOP = 88, BOTTOM = 96, CW = W - 2 * X, GAP = 64, LEFT = 440, RIGHT = CW - GAP - LEFT;
type P = {bg: string; ink: string; ink2: string; sub: string; rule: string; accent: string; accentBig: string; accentSmall: string};
const palette = (c: {primary?: string; background?: string} | undefined, warnings: string[]): P => {
let bg = '#0E0F11';
if (isHex(c?.background)) { if (lum(hex(c!.background!)) <= 0.03) bg = c!.background!.toUpperCase(); else warnings.push(`바탕 색 ${c!.background} 은 이 스타일(짙은 바탕)에 너무 밝아 #0E0F11 을 씀`); }
const given = isHex(c?.primary) ? c!.primary!.toUpperCase() : '#FF6A3D', ink = '#F3F1EC';
// 짙은 바탕에서 주 색이 어두우면(대비 4.5:1 미만) 같은 색상을 밝은 쪽으로 조금씩 섞어 올린다(최대 45%) — 어두운 화면용 틴트
let accent = given;
for (let k = 1; k <= 9 && ratio(hex(accent), hex(bg)) < 4.5; k++) accent = mix(given, '#FFFFFF', k * 0.05).toUpperCase();
if (accent !== given) warnings.push(`주 색 ${given} 은 짙은 바탕에서 어두워 밝힌 틴트 ${accent} 를 씀`);
const r = ratio(hex(accent), hex(bg));
if (r < 3) warnings.push(`주 색 ${accent} 은 바탕과 대비 ${r.toFixed(2)}:1 — 글자에는 쓰지 않고 점·막대에만 씀`);
return {bg, ink, ink2: mix(ink, bg, 0.2), sub: mix(ink, bg, 0.4), rule: mix(ink, bg, 0.8), accent, accentBig: r >= 3 ? accent : ink, accentSmall: r >= 4.5 ? accent : ink};
};
const base = (d: Deck): React.CSSProperties => ({fontFamily: SANS, wordBreak: d.lang === 'ko' ? 'keep-all' : 'normal'});
const st = {
hero: (d: Deck, p: P): React.CSSProperties => ({...base(d), fontFamily: SERIF, fontSize: d.lang === 'ko' ? 104 : 116, lineHeight: 1.08, fontWeight: 500, letterSpacing: d.lang === 'ko' ? '-0.03em' : '-0.025em', color: p.ink, textWrap: 'balance'}),
head: (d: Deck, p: P): React.CSSProperties => ({...base(d), fontFamily: SERIF, fontSize: d.lang === 'ko' ? 66 : 72, lineHeight: 1.16, fontWeight: 500, letterSpacing: d.lang === 'ko' ? '-0.03em' : '-0.018em', color: p.ink, textWrap: 'balance'}),
lead: (d: Deck, p: P): React.CSSProperties => ({...base(d), fontSize: 36, lineHeight: 1.42, fontWeight: 400, letterSpacing: '-0.01em', color: p.ink2, textWrap: 'pretty'}),
body: (d: Deck, p: P): React.CSSProperties => ({...base(d), fontSize: 32, lineHeight: 1.45, fontWeight: 400, color: p.ink2, textWrap: 'pretty'}),
small: (d: Deck, p: P): React.CSSProperties => ({...base(d), fontSize: 28, lineHeight: 1.4, fontWeight: 400, color: p.sub, textWrap: 'pretty'}),
name: (d: Deck, p: P, size = 44): React.CSSProperties => ({...base(d), fontFamily: SERIF, fontSize: size, lineHeight: 1.15, fontWeight: 500, letterSpacing: '-0.015em', color: p.ink}),
label: (d: Deck, p: P, c?: string): React.CSSProperties => d.lang === 'ko'
? {...base(d), fontSize: 28, lineHeight: 1.2, fontWeight: 500, color: c ?? p.sub, whiteSpace: 'nowrap'}
: {fontFamily: MONO, fontSize: 28, lineHeight: 1.2, fontWeight: 400, letterSpacing: '0.06em', textTransform: 'uppercase', color: c ?? p.sub, whiteSpace: 'nowrap'},
mono: (p: P, c?: string): React.CSSProperties => ({fontFamily: MONO, fontSize: 28, lineHeight: 1.2, fontWeight: 400, color: c ?? p.sub, whiteSpace: 'nowrap'}),
tab: (d: Deck, color: string): React.CSSProperties => ({...base(d), fontSize: 28, lineHeight: 1.2, fontWeight: 500, color, fontFeatureSettings: "'tnum' 1", whiteSpace: 'nowrap'}),
num: (d: Deck, size: number, color: string): React.CSSProperties => ({...base(d), fontFamily: SERIF, fontSize: size, lineHeight: 1, fontWeight: 500, letterSpacing: '-0.035em', color, whiteSpace: 'nowrap', fontVariantNumeric: 'lining-nums'}),
};
const rule = (p: P) => `2px solid ${p.rule}`;
const Spacer = ({f = 1}: {f?: number}) => <div style={{flex: f}} />;
// 머리말과 받침 줄 사이(위에서부터 쌓는다 — 잡지 단처럼)
const Gap = () => <div style={{height: 72, flexShrink: 0}} />;
const Dot = ({p, size = 16}: {p: P; size?: number}) => <O id="dot" s={{width: size, height: size, borderRadius: size / 2, background: p.accent, flex: 'none'}} />;
// ── 틀: 마스트헤드(브랜드 · 장 이름 · 쪽수 + 가는 선) → 왼쪽 1/3(장 번호·표시 점) · 오른쪽 2/3(본문) ──
const Masthead = ({d, p, right, label}: {d: Deck; p: P; right: string; label?: boolean}) => (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: 44, paddingBottom: 24, borderBottom: rule(p), boxSizing: 'content-box', flexShrink: 0}}>
{d.logo || d.logoDark ? <Pic a={(d.logoDark ?? d.logo)!} h={36} /> : <T id="brand" max={1} s={{...st.label(d, p, p.ink), ...(d.lang === 'ko' ? {fontWeight: 600} : {})}}>{d.brand}</T>}
<T id="page" max={1} s={label ? st.label(d, p) : st.mono(p)}>{right}</T>
</div>
);
const Frame = ({d, s, p, children, side, compact}: {d: Deck; s: Slide; p: P; children: React.ReactNode; side?: React.ReactNode; compact?: boolean}) => (
<div style={{position: 'absolute', inset: 0, background: p.bg, padding: `${TOP}px ${X}px ${BOTTOM}px`, display: 'flex', flexDirection: 'column'}}>
<Masthead d={d} p={p} right={`${pad2(s.n)} / ${pad2(s.total)}`} />
<div style={{flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: `${LEFT}px ${RIGHT}px`, columnGap: GAP, paddingTop: 56}}>
<div style={{display: 'flex', flexDirection: 'column'}}>
<div style={{display: 'flex', alignItems: 'center', gap: 16, height: 40}}>
<Dot p={p} />
<T id="label" max={1} s={st.label(d, p, p.ink)}>{LABEL[d.lang][s.kind]}</T>
</div>
{compact ? null : <T id="section" max={1} s={{...st.num(d, 168, p.accentBig), marginTop: 56, letterSpacing: '-0.04em'}}>{pad2(s.n)}</T>}
<Spacer />
{side}
</div>
<div style={{display: 'flex', flexDirection: 'column', minWidth: 0}}>{children}</div>
</div>
</div>
);
const Head = ({d, p, id, text, max}: {d: Deck; p: P; id: string; text: string; max?: number}) => (
<T id={id} max={max ?? 4} s={{...st.head(d, p), marginTop: -8}}>{glue(d.lang, text)}</T>
);
// 번호 목록: 가는 선으로 나눈 줄, 왼쪽 번호(모노) · 오른쪽 글자
const Rows = ({d, p, id, items, lines = 2}: {d: Deck; p: P; id: string; items: string[]; lines?: number}) => (
<div style={{borderBottom: rule(p)}}>{items.map((t, i) => (
<div key={i} style={{display: 'grid', gridTemplateColumns: '88px 1fr', borderTop: rule(p), padding: '24px 0 26px', alignItems: 'baseline'}}>
<T id={`${id}[${i}].n`} max={1} s={st.mono(p)}>{pad2(i + 1)}</T>
<T id={`${id}[${i}]`} max={lines} s={st.body(d, p)}>{t}</T>
</div>
))}</div>
);
const Cover = ({d, p}: {d: Deck; s: Slide; p: P}) => {
const meta: [string, string][] = [[d.lang === 'ko' ? '회사' : 'Company', d.brand]];
if (d.ask.web) meta.push([d.lang === 'ko' ? '웹' : 'Web', d.ask.web]);
if (d.ask.email) meta.push([d.lang === 'ko' ? '연락' : 'Contact', d.ask.email]);
return (
<div style={{position: 'absolute', inset: 0, background: p.bg, padding: `${TOP}px ${X}px ${BOTTOM}px`, display: 'flex', flexDirection: 'column'}}>
<Masthead d={d} p={p} right={LABEL[d.lang].cover} label />
<Spacer />
<div style={{display: 'flex', alignItems: 'center', gap: 18, marginBottom: 40}}>
<Dot p={p} size={20} />
{d.logo || d.logoDark ? <Pic a={(d.logoDark ?? d.logo)!} h={52} /> : <T id="brand.big" max={1} s={{...base(d), fontSize: 40, fontWeight: 600, letterSpacing: '-0.02em', color: p.ink, whiteSpace: 'nowrap'}}>{d.brand}</T>}
</div>
<T id="topic" max={3} s={{...st.hero(d, p), maxWidth: 1480}}>{glue(d.lang, d.topic)}</T>
<div style={{display: 'grid', gridTemplateColumns: `repeat(3, 1fr)`, columnGap: GAP, borderTop: rule(p), marginTop: 72, paddingTop: 24}}>
{meta.map(([k, v], i) => (
<div key={i}>
<T id={`cover.meta[${i}].k`} max={1} s={st.label(d, p)}>{k}</T>
<T id={`cover.meta[${i}].v`} max={1} s={{...st.body(d, p), color: p.ink, marginTop: 10, whiteSpace: 'nowrap'}}>{v}</T>
</div>
))}
</div>
</div>
);
};
const Problem = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}><Head d={d} p={p} id="problem.headline" text={d.problem.headline} /><Gap />
{d.problem.points.length ? <Rows d={d} p={p} id="problem.points" items={d.problem.points} /> : null}</Frame>
);
const WhyNow = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}><Head d={d} p={p} id="whyNow.headline" text={d.whyNow.headline} /><Gap />
{d.whyNow.points.length ? <Rows d={d} p={p} id="whyNow.points" items={d.whyNow.points} /> : null}</Frame>
);
const Solution = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}><Head d={d} p={p} id="solution.headline" text={d.solution.headline} /><Gap />
{d.solution.body ? <div style={{borderTop: rule(p), paddingTop: 28}}><T id="solution.body" max={4} s={st.lead(d, p)}>{d.solution.body}</T></div> : null}</Frame>
);
const Product = ({d, s, p}: {d: Deck; s: Slide; p: P}) => {
const f = d.product.features;
if (d.shots.length) {
// 오른쪽 칸: 머리말(2줄까지) → 스크린샷이 남은 높이를 채움 · 왼쪽 칸: 장 번호 아래 기능 목록
const top = TOP + 44 + 24 + 2 + 56, mh = H - BOTTOM - top - Math.round(72 * 1.16 * 2) + 8 - 48, each = d.shots.length === 2 ? (RIGHT - 32) / 2 : RIGHT;
const side = (
<div>{f.map((x, i) => (
<div key={i} style={{borderTop: rule(p), padding: '16px 0 18px'}}>
<T id={`product.features[${i}].name`} max={1} s={st.name(d, p, 34)}>{x.name}</T>
{x.detail ? <T id={`product.features[${i}].detail`} max={2} s={{...st.small(d, p), marginTop: 6}}>{x.detail}</T> : null}
</div>
))}</div>
);
return (
<Frame d={d} s={s} p={p} side={side}>
<Head d={d} p={p} id="product.headline" text={d.product.headline || DEFAULT_HEAD.product(d)} max={2} />
<Spacer />
<div style={{display: 'flex', gap: 32, alignItems: 'flex-end'}}>
{d.shots.map((a, i) => { const z = fit(a, each - 4, mh - 4); return (
<div key={i} style={{border: rule(p), borderRadius: 14, overflow: 'hidden', lineHeight: 0}}><Pic a={a} w={z.w} h={z.h} /></div>
); })}
</div>
</Frame>
);
}
return (
<Frame d={d} s={s} p={p}>
<Head d={d} p={p} id="product.headline" text={d.product.headline || DEFAULT_HEAD.product(d)} />
<Gap />
<div style={{borderBottom: rule(p)}}>{f.map((x, i) => (
<div key={i} style={{display: 'grid', gridTemplateColumns: '88px 360px 1fr', columnGap: 0, borderTop: rule(p), padding: '26px 0 30px', alignItems: 'baseline'}}>
<T id={`product.features[${i}].n`} max={1} s={st.mono(p)}>{pad2(i + 1)}</T>
<T id={`product.features[${i}].name`} max={2} s={{...st.name(d, p, 40), paddingRight: 32}}>{x.name}</T>
{x.detail ? <T id={`product.features[${i}].detail`} max={2} s={st.body(d, p)}>{x.detail}</T> : <div />}
</div>
))}</div>
</Frame>
);
};
const Market = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}
side={d.market.source ? <T id="market.source" max={3} s={st.small(d, p)}>{(d.lang === 'ko' ? '출처 · ' : 'Source · ') + d.market.source}</T> : null}>
{d.market.headline ? <><Head d={d} p={p} id="market.headline" text={d.market.headline} max={2} /><Gap /></> : null}
<div style={{borderBottom: rule(p)}}>{d.market.rows.map((r, i) => (
<div key={r.key} style={{display: 'grid', gridTemplateColumns: '88px 1fr 520px', borderTop: rule(p), padding: '24px 0 26px', minHeight: 176, alignItems: 'baseline'}}>
<T id={`market.${r.key.toLowerCase()}.key`} max={1} s={st.mono(p)}>{r.key}</T>
<T id={`market.${r.key.toLowerCase()}.value`} max={1} s={st.num(d, d.market.headline ? 96 : 116, p.ink)}><Unit text={r.value} /></T>
<T id={`market.${r.key.toLowerCase()}.label`} max={2} s={st.body(d, p)}>{r.label}</T>
</div>
))}</div>
</Frame>
);
const Model = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}>
{d.model.headline ? <><Head d={d} p={p} id="model.headline" text={d.model.headline} max={2} /><Gap /></> : null}
{d.model.price ? (
<div style={{display: 'flex', alignItems: 'baseline', gap: 32, marginBottom: 32}}>
<T id="model.price" max={1} s={st.num(d, 152, p.ink)}><Unit text={d.model.price} /></T>
{d.model.unit ? <T id="model.unit" max={2} s={{...st.lead(d, p), color: p.ink}}>{d.model.unit}</T> : null}
</div>
) : null}
{d.model.points.length ? <Rows d={d} p={p} id="model.points" items={d.model.points} lines={1} /> : null}
</Frame>
);
const Bars = ({d, p, w, h}: {d: Deck; p: P; w: number; h: number}) => {
const se = d.traction.series!, max = Math.max(...se.points.map(([, v]) => v), 1e-9), n = se.points.length;
const gap = n > 8 ? 14 : 24, bw = (w - gap * (n - 1)) / n, top = 40 + 44, plot = h - top - 50;
return (
<div style={{position: 'relative', width: w, height: h}}>
<T id="traction.series.label" max={1} s={{...st.small(d, p), position: 'absolute', left: 0, top: 0, whiteSpace: 'nowrap'}}>{se.label}</T>
{se.points.map(([k, v], i) => {
const bh = Math.max(4, (v / max) * plot), x = i * (bw + gap);
return (
<React.Fragment key={i}>
<O id={`traction.series.points[${i}]`} s={{position: 'absolute', left: x, top: top + plot - bh, width: bw, height: bh, background: i === n - 1 ? p.ink : p.rule}} />
<T id={`traction.series.points[${i}].value`} max={1} s={{...st.tab(d, i === n - 1 ? p.ink : p.ink2), position: 'absolute', left: x - 16, width: bw + 32, textAlign: 'center', top: top + plot - bh - 42}}>{v.toLocaleString('en-US')}</T>
<T id={`traction.series.points[${i}].key`} max={1} s={{...st.tab(d, p.sub), position: 'absolute', left: x - 16, width: bw + 32, textAlign: 'center', top: top + plot + 14}}>{k}</T>
</React.Fragment>
);
})}
<O id="traction.series.base" s={{position: 'absolute', left: 0, top: top + plot, width: w, height: 2, background: p.sub}} />
</div>
);
};
const Traction = ({d, s, p}: {d: Deck; s: Slide; p: P}) => {
const m = d.traction.metrics, se = d.traction.series;
const metrics = (size: number, col: boolean) => (
<div style={{display: 'grid', gridTemplateColumns: col ? '1fr' : `repeat(${m.length}, 1fr)`, columnGap: 40}}>{m.map((x, i) => (
<div key={i} style={{borderTop: rule(p), padding: '22px 0 26px'}}>
<T id={`traction.metrics[${i}].value`} max={1} s={st.num(d, size, p.ink)}><Unit text={x.value} /></T>
<T id={`traction.metrics[${i}].label`} max={2} s={{...st.small(d, p), marginTop: 28}}>{x.label}</T>
</div>
))}</div>
);
return (
<Frame d={d} s={s} p={p} side={se && m.length ? metrics(m.length > 1 ? 80 : 112, true) : null}>
{d.traction.headline ? <><Head d={d} p={p} id="traction.headline" text={d.traction.headline} max={2} /><Gap /></> : null}
{se ? <><Spacer /><Bars d={d} p={p} w={RIGHT} h={d.traction.headline ? 440 : 600} /></> : (
<div style={{borderBottom: rule(p)}}>{m.map((x, i) => (
<div key={i} style={{display: 'grid', gridTemplateColumns: '88px 1fr 520px', borderTop: rule(p), padding: '24px 0 26px', alignItems: 'baseline'}}>
<div />
<T id={`traction.metrics[${i}].value`} max={1} s={st.num(d, 96, p.ink)}><Unit text={x.value} /></T>
<T id={`traction.metrics[${i}].label`} max={2} s={st.body(d, p)}>{x.label}</T>
</div>
))}</div>
)}
</Frame>
);
};
const Team = ({d, s, p}: {d: Deck; s: Slide; p: P}) => (
<Frame d={d} s={s} p={p}>
<Head d={d} p={p} id="teamHeadline" text={d.teamHeadline || DEFAULT_HEAD.team(d)} max={2} />
<Gap />
<div style={{borderBottom: rule(p)}}>{d.team.map((x, i) => (
<div key={i} style={{display: 'grid', gridTemplateColumns: '400px 1fr', borderTop: rule(p), padding: '24px 0 28px', alignItems: 'baseline'}}>
<div>
<T id={`team[${i}].name`} max={1} s={st.name(d, p, 48)}>{x.name}</T>
{x.role ? <T id={`team[${i}].role`} max={1} s={{...st.small(d, p), color: p.ink2, marginTop: 8}}>{x.role}</T> : null}
</div>
{x.line ? <T id={`team[${i}].line`} max={2} s={st.body(d, p)}>{x.line}</T> : <div />}
</div>
))}</div>
</Frame>
);
const Ask = ({d, s, p}: {d: Deck; s: Slide; p: P}) => {
const a = d.ask, contact = [a.name, a.email, a.web].filter(Boolean);
// 금액이 없으면 연락처가 이 장의 받침(오른쪽 칸에 크게)
if (!a.amount) return (
<Frame d={d} s={s} p={p}>
<Head d={d} p={p} id="ask.open" text={OPEN_ASK[d.lang]} />
<Gap />
{contact.length ? <div style={{borderTop: rule(p), borderBottom: rule(p), padding: '28px 0 32px'}}>
{contact.map((c, i) => <T key={i} id={`ask.contact[${i}]`} max={1} s={i === 0 && a.name ? {...st.name(d, p, 48), marginBottom: 12} : {...st.lead(d, p), color: p.ink, whiteSpace: 'nowrap'}}>{c}</T>)}
</div> : null}
</Frame>
);
return (
<Frame d={d} s={s} p={p}>
<div style={{display: 'flex', alignItems: 'baseline', gap: 36, marginTop: -8}}>
<T id="ask.amount" max={1} s={st.num(d, 184, p.ink)}><Unit text={a.amount} /></T>
{a.round ? <T id="ask.round" max={1} s={{...st.name(d, p, 56), whiteSpace: 'nowrap'}}>{a.round}</T> : null}
</div>
<Gap />
{a.use.length ? (
<div style={{borderBottom: rule(p)}}>
<T id="ask.use.label" max={1} s={{...st.label(d, p), marginBottom: 18}}>{d.lang === 'ko' ? '쓰는 곳' : 'Use of funds'}</T>
{a.use.map((u, i) => (
<div key={i} style={{display: 'grid', gridTemplateColumns: '300px 1fr 120px', columnGap: 32, alignItems: 'center', borderTop: rule(p), padding: '18px 0 20px'}}>
<T id={`ask.use[${i}].label`} max={1} s={st.body(d, p)}>{u.label}</T>
<O id={`ask.use[${i}].bar`} s={{height: 10, width: `${u.percent}%`, background: i === 0 ? p.ink : p.sub}} />
<T id={`ask.use[${i}].percent`} max={1} s={{...st.num(d, 40, p.ink), textAlign: 'right', letterSpacing: '-0.01em'}}>{u.percent}%</T>
</div>
))}
</div>
) : null}
<Spacer />
{contact.length ? (
<div style={{display: 'grid', gridTemplateColumns: '300px 1fr', columnGap: 32, alignItems: 'baseline'}}>
<T id="ask.contact.label" max={1} s={st.label(d, p)}>{d.lang === 'ko' ? '연락' : 'Contact'}</T>
<div style={{display: 'flex', gap: 40, flexWrap: 'wrap'}}>{contact.map((c, i) => <T key={i} id={`ask.contact[${i}]`} max={1} s={{...st.body(d, p), color: i === 0 && a.name ? p.ink : p.ink2, fontWeight: i === 0 && a.name ? 600 : 400, whiteSpace: 'nowrap'}}>{c}</T>)}</div>
</div>
) : null}
</Frame>
);
};
const SLIDES = {cover: Cover, problem: Problem, solution: Solution, product: Product, whyNow: WhyNow, market: Market, model: Model, traction: Traction, team: Team, ask: Ask};
deck<P>({
name: 'deck-editorial-dark',
css: ['https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css', 'https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500', 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400'],
specs: (d, all) => [["400 30px 'Pretendard Variable'", all], ["500 76px 'Noto Serif KR'", all], ["400 28px 'IBM Plex Mono'", '0123456789/ ABCDEFGHIJKLMNOPQRSTUVWXYZ.,$%']],
palette,
Slide: ({d, s, p}) => { const C = SLIDES[s.kind]; return <C d={d} s={s} p={p} />; },
});
```
`src/deck.tsx`
```tsx
// ── Duchamp 덱 공용 · 사실표(props.json) → 장 목록, 폰트, 측정 검사, 장 전환 ──
// 장 하나 = 프레임 하나. 모든 장을 한 화면에 겹쳐 놓고(지금 장만 보임) 처음 한 번 전부 잰다:
// 넘침(줄 수 한도·가로 넘침) · 글자 겹침 · 안전 여백 64px · 글자 크기 ≥ 28px · 대비(4.5:1, 48px 이상 3:1) · 대체 글꼴.
// 한도를 넘으면 글자를 줄이지 않는다 — 몇 자를 줄여야 그 줄 수에 들어가는지 재서 보고한다.
import React, {useEffect, useState} from 'react';
import {AbsoluteFill, Composition, Img, cancelRender, continueRender, delayRender, registerRoot, staticFile, useCurrentFrame} from './duchamp-frame';
export const W = 1920, H = 1080, FPS = 30, SAFE = 64, MIN_PX = 28;
// ── 사실표 ──
export type Item = {name?: string; detail?: string};
export type Num = {value?: string; label?: string};
export type Person = {name?: string; role?: string; line?: string};
export type Facts = {
problem?: {headline?: string; points?: string[]};
solution?: {headline?: string; body?: string};
product?: {headline?: string; features?: Item[]};
whyNow?: {headline?: string; points?: string[]};
market?: {headline?: string; tam?: Num; sam?: Num; som?: Num; source?: string};
model?: {headline?: string; price?: string; unit?: string; points?: string[]};
traction?: {headline?: string; metrics?: Num[]; series?: {label?: string; points?: [string, number][]}};
team?: Person[];
teamHeadline?: string;
ask?: {amount?: string; round?: string; use?: {label?: string; percent?: number}[]; contact?: {name?: string; email?: string; web?: string}};
};
export type Asset = {src: string; w: number; h: number};
export type Props = {
brand: string; topic: string; colors?: {primary?: string; background?: string}; facts: Facts;
// render.mjs 가 채운다: 로고·스크린샷을 public/assets/ 로 복사한 주소와 픽셀 크기
assets?: {logo?: Asset | null; logoDark?: Asset | null; shots?: Asset[]};
};
export type Kind = 'cover' | 'problem' | 'solution' | 'product' | 'whyNow' | 'market' | 'model' | 'traction' | 'team' | 'ask';
export type Slide = {kind: Kind; n: number; total: number};
export type Lang = 'en' | 'ko';
export const has = (s?: string | null): s is string => typeof s === 'string' && s.trim().length > 0;
const list = (a?: (string | undefined)[]) => (a ?? []).filter(has);
// 빈 칸을 걷어 낸 사실표 — 장 컴포넌트는 이것만 본다
export type Deck = {
brand: string; topic: string; lang: Lang; logo: Asset | null; logoDark: Asset | null; shots: Asset[];
problem: {headline: string; points: string[]}; solution: {headline: string; body: string};
product: {headline: string; features: Required<Item>[]};
whyNow: {headline: string; points: string[]};
market: {headline: string; rows: {key: 'TAM' | 'SAM' | 'SOM'; value: string; label: string}[]; source: string};
model: {headline: string; price: string; unit: string; points: string[]};
traction: {headline: string; metrics: Required<Num>[]; series: {label: string; points: [string, number][]} | null};
team: Required<Person>[]; teamHeadline: string;
ask: {amount: string; round: string; use: {label: string; percent: number}[]; name: string; email: string; web: string};
};
export function normalize(p: Props): {deck: Deck; errors: string[]; warnings: string[]} {
const f = p.facts ?? {}, errors: string[] = [], warnings: string[] = [];
const t = (s?: string) => (has(s) ? s.trim() : '');
const text = JSON.stringify([p.brand, p.topic, f]);
const lang: Lang = /[가-힣]/.test(text) ? 'ko' : 'en';
const features = (f.product?.features ?? []).filter((x) => has(x?.name)).map((x) => ({name: t(x.name), detail: t(x.detail)}));
const metrics = (f.traction?.metrics ?? []).filter((x) => has(x?.value)).map((x) => ({value: t(x.value), label: t(x.label)}));
const pts = (f.traction?.series?.points ?? []).filter((x) => Array.isArray(x) && has(String(x[0])) && Number.isFinite(Number(x[1])));
const series = pts.length ? {label: t(f.traction?.series?.label), points: pts.map(([a, b]) => [String(a), Number(b)] as [string, number])} : null;
const rows = (['tam', 'sam', 'som'] as const).filter((k) => has(f.market?.[k]?.value)).map((k) => ({key: k.toUpperCase() as 'TAM', value: t(f.market?.[k]?.value), label: t(f.market?.[k]?.label)}));
const team = (f.team ?? []).filter((x) => has(x?.name)).map((x) => ({name: t(x.name), role: t(x.role), line: t(x.line)}));
const use = (f.ask?.use ?? []).filter((x) => has(x?.label) && Number.isFinite(Number(x?.percent))).map((x) => ({label: t(x.label), percent: Number(x.percent)}));
const deck: Deck = {
brand: t(p.brand), topic: t(p.topic), lang, logo: p.assets?.logo ?? null, logoDark: p.assets?.logoDark ?? null, shots: (p.assets?.shots ?? []).slice(0, 2),
problem: {headline: t(f.problem?.headline), points: list(f.problem?.points)}, solution: {headline: t(f.solution?.headline), body: t(f.solution?.body)},
product: {headline: t(f.product?.headline), features}, whyNow: {headline: t(f.whyNow?.headline), points: list(f.whyNow?.points)},
market: {headline: t(f.market?.headline), rows, source: t(f.market?.source)},
model: {headline: t(f.model?.headline), price: t(f.model?.price), unit: t(f.model?.unit), points: list(f.model?.points)},
traction: {headline: t(f.traction?.headline), metrics, series},
team, teamHeadline: t(f.teamHeadline), ask: {amount: t(f.ask?.amount), round: t(f.ask?.round), use, name: t(f.ask?.contact?.name), email: t(f.ask?.contact?.email), web: t(f.ask?.contact?.web)},
};
// 반드시 있어야 하는 칸과 개수 한도(넘으면 렌더하지 않고 알려 준다)
if (!deck.brand) errors.push('brand(브랜드명)가 비었음');
if (!deck.topic) errors.push('topic(주제 — 무엇을 누구에게 한 문장)이 비었음');
if (!deck.problem.headline) errors.push('facts.problem.headline(문제 한 줄)이 비었음');
if (!deck.solution.headline) errors.push('facts.solution.headline(해결 한 줄)이 비었음');
const cap = (n: number, max: number, what: string) => { if (n > max) errors.push(`${what} ${n}개 → 최대 ${max}개`); };
cap(deck.problem.points.length, 3, 'facts.problem.points'); cap(deck.whyNow.points.length, 3, 'facts.whyNow.points');
cap(features.length, 3, 'facts.product.features'); cap(deck.model.points.length, 3, 'facts.model.points');
cap(metrics.length, 3, 'facts.traction.metrics'); cap(team.length, 4, 'facts.team'); cap(use.length, 5, 'facts.ask.use');
if (series && (series.points.length < 2 || series.points.length > 12)) errors.push(`facts.traction.series.points ${series.points.length}개 → 2~12개`);
if (series && series.points.some(([, v]) => v < 0)) errors.push('facts.traction.series 값은 0 이상');
if (series && !series.label) errors.push('facts.traction.series.label(무슨 숫자인지·단위)이 비었음 — 차트에 이름 없는 숫자를 그리지 않는다');
if (use.length && Math.abs(use.reduce((s, x) => s + x.percent, 0) - 100) > 0.5) errors.push(`facts.ask.use 비율 합이 ${use.reduce((s, x) => s + x.percent, 0)}% → 100% 로`);
if (use.length && !deck.ask.amount) warnings.push('용도(use)는 금액(amount)이 있을 때만 그린다 — 금액 없이 비율만 보이지 않게');
if (features.some((x) => !x.detail)) warnings.push('기능 설명(detail)이 빈 기능이 있음');
if (rows.some((x) => !x.label)) errors.push('facts.market 의 값마다 label(무엇의 크기인지)이 필요');
if (rows.length && !deck.market.source) warnings.push('시장 숫자에 출처(source)가 없음 — 투자자가 가장 먼저 묻는다');
if (metrics.some((x) => !x.label)) errors.push('facts.traction.metrics 의 값마다 label 이 필요');
if ((f.product?.features ?? []).length && !features.length) warnings.push('기능 이름(name)이 모두 비어 제품 장 기능이 없음');
return {deck, errors, warnings};
}
// 장 목록: 표지·문제·해결·요청은 늘 있고, 나머지는 사실이 있을 때만(숫자 장은 숫자가 있을 때만)
export function slidesOf(d: Deck): Slide[] {
const k: Kind[] = ['cover', 'problem', 'solution'];
if (d.shots.length || d.product.features.length) k.push('product');
if (d.whyNow.headline) k.push('whyNow');
if (d.market.rows.length) k.push('market');
if (d.model.price || d.model.headline) k.push('model');
if (d.traction.metrics.length || d.traction.series) k.push('traction');
if (d.team.length) k.push('team');
k.push('ask');
return k.map((kind, i) => ({kind, n: i + 1, total: k.length}));
}
export const LABEL: Record<Lang, Record<Kind, string>> = {
en: {cover: 'Pitch deck', problem: 'Problem', solution: 'Solution', product: 'Product', whyNow: 'Why now', market: 'Market', model: 'Business model', traction: 'Traction', team: 'Team', ask: 'The ask'},
ko: {cover: '소개서', problem: '문제', solution: '해결', product: '제품', whyNow: '왜 지금', market: '시장', model: '사업 모델', traction: '성과', team: '팀', ask: '제안'},
};
// 머리말을 비운 제품·팀 장의 고정 문구(사실·숫자 없음)
const josa = (w: string, a: string, b: string) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 ? ((c - 0xac00) % 28 ? a : b) : `${a}(${b})`; };
export const DEFAULT_HEAD = {
product: (d: {lang: Lang; brand: string}) => (d.lang === 'ko' ? `${d.brand}의 핵심 기능` : `What ${d.brand} does.`),
team: (d: {lang: Lang; brand: string}) => (d.lang === 'ko' ? `${d.brand}${josa(d.brand, '을', '를')} 만드는 사람들` : `The people building ${d.brand}.`),
};
// 금액 없는 요청 장의 문장(사용자 사실이 아닌 고정 문구 — 숫자·주장 없음)
export const OPEN_ASK: Record<Lang, string> = {en: 'We’re looking for people to build this with.', ko: '함께할 분을 찾습니다.'};
// 한글 제목 줄바꿈: 한 글자 낱말(한·열·월 …)과 숫자로 시작하는 세 글자 이하 낱말(1인·3년 …)은 다음 낱말과 붙인다(줄 끝에 홀로 남지 않게) — 글자는 그대로, 공백만 줄바꿈 없는 공백
// (줄바꿈 없는 공백은 글자 코드로 만든다 — 레시피를 옮겨 적을 때 이스케이프가 다른 글자로 바뀌지 않게)
const NBSP = String.fromCharCode(160), GLUE = new RegExp(`(^|[ ${NBSP}])(\\S|\\d\\S{0,2}) (?=\\S)`, 'g');
export const glue = (lang: Lang, t: string) => (lang === 'ko' ? t.replace(GLUE, (_, a, w) => `${a}${w}${NBSP}`) : t);
export const pad2 = (n: number) => String(n).padStart(2, '0');
// ── 색 ──
export const hex = (h: string) => { const n = parseInt(h.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const lin = (c: number) => { const s = c / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
export const lum = (rgb: number[]) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]);
export const ratio = (a: number[], b: number[]) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
export const isHex = (s?: string): s is string => typeof s === 'string' && /^#[0-9a-f]{6}$/i.test(s.trim());
// 두 색 섞기(a → b 로 t 만큼)
export const mix = (a: string, b: string, t: number) => '#' + hex(a).map((v, i) => Math.round(v + (hex(b)[i] - v) * t).toString(16).padStart(2, '0')).join('');
// ── 폰트: 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);
}
await document.fonts.ready;
};
// ── 글자 상자: id = 사실표 경로(검사 보고에 그대로), max = 줄 수 한도 ──
// 하이픈으로 이은 낱말(machine-readable)은 한 덩어리로 — 줄 끝에서 하이픈 뒤가 갈라지지 않게(글자는 그대로)
const nbh = (c: React.ReactNode) => (typeof c === 'string' && /\w-\w/.test(c)
? c.split(/(\S*\w-\w\S*)/).map((x, i) => (i % 2 ? <span key={i} style={{whiteSpace: 'nowrap'}}>{x}</span> : x))
: c);
export const T = ({id, max, s, children}: {id: string; max?: number; s: React.CSSProperties; children: React.ReactNode}) => (
<div data-t={id} data-max={max ?? 0} style={s}>{React.Children.map(children, nbh)}</div>
);
// 큰 숫자 안 한글 단위(조·억·원·곳·월)는 숫자보다 작게(0.6배) — 숫자가 주인공
export const Unit = ({text}: {text: string}) => (
<>{text.split(/([가-힣][가-힣 ]*)/).filter(Boolean).map((x, i) => /[가-힣]/.test(x) ? <span key={i} style={{fontSize: '0.6em', letterSpacing: '-0.01em'}}>{x}</span> : <React.Fragment key={i}>{x}</React.Fragment>)}</>
);
// 글자 아닌 것(로고·스크린샷·차트 막대): 여백·겹침 검사에 넣는다
export const O = ({id, s, children}: {id: string; s: React.CSSProperties; children?: React.ReactNode}) => <div data-o={id} style={s}>{children}</div>;
export const Pic = ({a, h, w, s}: {a: Asset; h?: number; w?: number; s?: React.CSSProperties}) => {
const ww = w ?? (h! * a.w) / a.h, hh = h ?? (w! * a.h) / a.w;
return <Img data-o="image" src={staticFile(a.src)} style={{display: 'block', width: ww, height: hh, ...s}} />;
};
// 그림을 상자(mw × mh) 안에 비율 그대로 가장 크게
export const fit = (a: Asset, mw: number, mh: number) => { const k = Math.min(mw / a.w, mh / a.h); return {w: Math.round(a.w * k), h: Math.round(a.h * k)}; };
// ── 측정 ──
type R = {x: number; y: number; w: number; h: number};
const rel = (r: DOMRect, b: DOMRect): R => ({x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height});
const lineRects = (el: Element, b: DOMRect): R[] => {
const range = document.createRange(); range.selectNodeContents(el);
return [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5).map((r) => rel(r, b));
};
// 줄 수: 조각의 세로 가운데가 이미 있는 줄 안에 들면 같은 줄(크기가 다른 글자 조각 — 숫자와 작은 단위 — 도 한 줄로)
const lineCount = (rs: R[]) => {
const lines: [number, number][] = [];
for (const r of [...rs].sort((a, b) => b.h - a.h)) { const c = r.y + r.h / 2; if (!lines.some(([t, b]) => c >= t && c <= b)) lines.push([r.y, r.y + r.h]); }
return lines.length;
};
const cross = (a: R, c: R, pad = 0) => a.x < c.x + c.w - pad && c.x < a.x + a.w - pad && a.y < c.y + c.h - pad && c.y < a.y + a.h - pad;
const rgba = (s: string) => (/rgba?\(([^)]+)\)/.exec(s)?.[1].split(/[ ,/]+/).filter(Boolean).map(Number) ?? [0, 0, 0, 0]);
const bgOf = (el: Element): number[] => {
for (let e: Element | null = el; e; e = e.parentElement) { const c = rgba(getComputedStyle(e).backgroundColor); if ((c[3] ?? 1) > 0.5) return c.slice(0, 3); }
return [255, 255, 255];
};
// 한도 줄 수에 들어가는 가장 긴 앞부분 → 넘친 글자 수(공백 포함)
const cutNeeded = (el: HTMLElement, max: number) => {
const text = el.textContent ?? '', chars = Array.from(text);
const c = el.cloneNode(false) as HTMLElement;
c.style.position = 'absolute'; c.style.visibility = 'hidden'; c.style.left = '0'; c.style.top = '0';
c.style.width = el.getBoundingClientRect().width + 'px'; c.style.maxWidth = 'none'; c.style.height = 'auto';
el.parentElement!.appendChild(c);
const fits = (k: number) => { c.textContent = chars.slice(0, k).join(''); return lineCount(lineRects(c, c.getBoundingClientRect())) <= max && c.scrollWidth <= c.clientWidth + 1; };
let lo = 0, hi = chars.length;
while (lo < hi) { const m = Math.ceil((lo + hi) / 2); if (fits(m)) lo = m; else hi = m - 1; }
c.remove();
return chars.length - lo;
};
// 대체 글꼴: ① 그 글꼴의 @font-face 들의 unicode-range 가 글자를 모두 덮는지(공백 제외) ② 같은 글자를 '그 글꼴, monospace' 와 '그 글꼴, serif' 로 재서 폭이 같은지
// ①은 시스템이 monospace·serif 를 같은 한글 글꼴로 바꿔 ②가 못 잡는 경우(예: 모노 글꼴에 한글)를 잡는다
const canvas = typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d')! : null;
const ranges = (u: string) => u.split(',').map((x) => x.trim().replace(/^U\+/i, '')).filter(Boolean).map((x) => {
if (x.includes('?')) return [parseInt(x.replace(/\?/g, '0'), 16), parseInt(x.replace(/\?/g, 'F'), 16)];
const [a, b] = x.split('-'); return [parseInt(a, 16), parseInt(b ?? a, 16)];
});
const uncovered = (fam: string, text: string) => {
const name = fam.replace(/["']/g, ''), rs = [...document.fonts].filter((f) => f.family.replace(/["']/g, '') === name).flatMap((f) => ranges(f.unicodeRange || 'U+0-10FFFF'));
return Array.from(text).filter((c) => !/\s/.test(c)).some((c) => { const n = c.codePointAt(0)!; return !rs.some(([a, b]) => n >= a && n <= b); });
};
const fallbackIn = (el: HTMLElement) => {
const cs = getComputedStyle(el), fam = cs.fontFamily.split(',')[0].trim(), text = el.textContent ?? '';
const base = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${fam}`;
canvas!.font = base + ', monospace'; const a = canvas!.measureText(text).width;
canvas!.font = base + ', serif'; const b = canvas!.measureText(text).width;
const loaded = [...document.fonts].some((f) => f.family.replace(/["']/g, '') === fam.replace(/["']/g, '') && f.status === 'loaded');
return Math.abs(a - b) > 0.5 || !loaded || uncovered(fam, text) ? fam : null;
};
export type SlideCheck = {n: number; kind: Kind; overflow: string[]; overlap: string[]; margin: string[]; small: string[]; contrast: string[]; fallback: string[]};
function measure(root: HTMLElement, slides: Slide[], lang: Lang) {
const out: SlideCheck[] = [];
for (const s of slides) {
const el = root.querySelector(`[data-slide="${s.n}"]`) as HTMLElement, b = el.getBoundingClientRect();
const r: SlideCheck = {n: s.n, kind: s.kind, overflow: [], overlap: [], margin: [], small: [], contrast: [], fallback: []};
const texts = [...el.querySelectorAll('[data-t]')] as HTMLElement[];
const boxes: {id: string; el: Element; rs: R[]}[] = [];
for (const t of texts) {
const id = t.dataset.t!, max = Number(t.dataset.max), rs = lineRects(t, b), cs = getComputedStyle(t), lines = lineCount(rs);
if (!(t.textContent ?? '').trim()) continue;
if (max && lines > max) {
const n = cutNeeded(t, max);
r.overflow.push(lang === 'ko' ? `${id}: ${lines}줄(한도 ${max}줄) → ${n}자 줄이기` : `${id}: ${lines} lines (max ${max}) → cut ${n} characters`);
}
if (t.scrollWidth > t.clientWidth + 1) r.overflow.push(`${id}: 가로로 넘침(${t.scrollWidth - t.clientWidth}px) — 끊을 수 없는 긴 단어·주소`);
for (const q of rs) if (q.x < SAFE - 0.5 || q.y < SAFE - 0.5 || q.x + q.w > W - SAFE + 0.5 || q.y + q.h > H - SAFE + 0.5) { r.margin.push(`${id}: 안전 여백 ${SAFE}px 침범`); break; }
const px = parseFloat(cs.fontSize);
if (px < MIN_PX) r.small.push(`${id}: ${px}px < ${MIN_PX}px`);
const c = ratio(rgba(cs.color).slice(0, 3), bgOf(t)), need = px >= 48 ? 3 : 4.5;
if (c < need) r.contrast.push(`${id}: ${c.toFixed(2)}:1 < ${need}:1`);
const fb = fallbackIn(t);
if (fb) r.fallback.push(`${id}: ${fb} 에 없는 글자 → 대체 글꼴`);
boxes.push({id, el: t, rs});
}
for (const o of [...el.querySelectorAll('[data-o]')] as HTMLElement[]) {
const q = rel(o.getBoundingClientRect(), b), id = o.dataset.o!;
if (q.x < SAFE - 0.5 || q.y < SAFE - 0.5 || q.x + q.w > W - SAFE + 0.5 || q.y + q.h > H - SAFE + 0.5) r.margin.push(`${id}: 안전 여백 ${SAFE}px 침범`);
// 그림·막대 위에 다른 글자가 얹히면 겹침(그 상자 안의 이름표는 제외)
for (const x of boxes) if (!o.contains(x.el) && x.rs.some((l) => cross(l, q, 1))) r.overlap.push(`${x.id} ↔ ${id}`);
}
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const A = boxes[i], B = boxes[j];
if (A.el.contains(B.el) || B.el.contains(A.el)) continue;
if (A.rs.some((p) => B.rs.some((q) => cross(p, q, 1)))) r.overlap.push(`${A.id} ↔ ${B.id}`);
}
out.push(r);
}
// 위 줄 자리: 쪽수(page)가 모든 장에서 같은 자리여야 한다(본문이 넘쳐 위 줄이 밀리면 실패)
const pos = slides.map((s) => { const el = root.querySelector(`[data-slide="${s.n}"]`)!, pg = el.querySelector('[data-t="page"]'); return pg ? rel(pg.getBoundingClientRect(), el.getBoundingClientRect()) : null; });
const ref = pos.find((x) => x);
pos.forEach((q, i) => { if (q && ref && (Math.abs(q.x + q.w - ref.x - ref.w) > 0.5 || Math.abs(q.y - ref.y) > 0.5)) out[i].margin.push(`page: 위 줄 자리가 다른 장과 다름(${Math.round(q.y - ref.y)}px) — 본문이 넘쳐 위 줄을 밀었다. 이 장 글자를 줄여야 한다`); });
return out;
}
// ── 스타일이 넘기는 것 ──
export type Style<P> = {
name: string;
css: string[];
// 폰트 확인: [CSS font 값, 그 글꼴로 그릴 글자] — 덱의 모든 글자를 넣어 한글 조각까지 미리 받는다
specs: (d: Deck, all: string) => [string, string][];
palette: (colors: Props['colors'], warnings: string[]) => P;
Slide: React.ComponentType<{d: Deck; s: Slide; p: P}>;
};
const allText = (d: Deck) => Array.from(new Set(Array.from(JSON.stringify(d) + '0123456789/—–·%$€£₩.,:;!?()[]’' + Object.values(LABEL[d.lang]).join('') + OPEN_ASK[d.lang] + DEFAULT_HEAD.product(d) + DEFAULT_HEAD.team(d) + '작동 방식 쓰는 곳 연락 출처 How it works Use of funds Contact Source'))).join('');
export function deck<P>(style: Style<P>) {
const Main = (props: Props) => {
const frame = useCurrentFrame();
const [ready, setReady] = useState(false);
const [handle] = useState(() => delayRender('폰트·측정'));
const {deck: d, errors, warnings} = normalize(props);
const slides = slidesOf(d);
const p = style.palette(props.colors, warnings);
useEffect(() => {
if (errors.length) { console.log('DUCHAMP_CHECK ' + JSON.stringify({ok: false, style: style.name, input: errors, warnings})); continueRender(handle); return; }
loadFonts(style.css, style.specs(d, allText(d))).then(() => setReady(true)).catch((e) => cancelRender(e));
}, []);
useEffect(() => {
if (!ready) return;
try {
const res = measure(document.querySelector('[data-deck]') as HTMLElement, slides, d.lang);
const failures = res.flatMap((r) => [...r.overflow, ...r.overlap, ...r.margin, ...r.small, ...r.contrast, ...r.fallback].map((m) => `${pad2(r.n)} ${r.kind} · ${m}`));
console.log('DUCHAMP_CHECK ' + JSON.stringify({ok: failures.length === 0, style: style.name, lang: d.lang, slides: slides.map((s) => s.kind), failures, warnings, detail: res}));
continueRender(handle);
} catch (e) { cancelRender(e); }
}, [ready]);
if (errors.length || !ready) return <AbsoluteFill />;
return (
<AbsoluteFill data-deck="" lang={d.lang}>
{slides.map((s) => (
<div key={s.n} data-slide={s.n} style={{position: 'absolute', left: 0, top: 0, width: W, height: H, overflow: 'hidden', visibility: s.n - 1 === frame ? 'visible' : 'hidden'}}>
<style.Slide d={d} s={s} p={p} />
</div>
))}
</AbsoluteFill>
);
};
const Root = () => (
<Composition id="Main" component={Main} width={W} height={H} fps={FPS} durationInFrames={1}
defaultProps={{brand: '', topic: '', facts: {}} as Props}
calculateMetadata={({props}) => {
const {deck: d, errors, warnings} = normalize(props as Props);
const slides = slidesOf(d);
style.palette((props as Props).colors, warnings);
// render.mjs 가 렌더 전에 읽는다(장 목록·입력 오류)
(globalThis as any).__DUCHAMP_DECK = {slides: slides.map((s) => s.kind), lang: d.lang, errors, warnings};
return {durationInFrames: slides.length};
}} />
);
registerRoot(Root);
}
```
`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));
};
```
## 5. 실행
```bash
npm install --no-audit --no-fund
node render.mjs
```
- 장 10개 기준 첫 실행 30~60초(Chrome 받기 포함), 그다음부터 5초 안팎.
- 종료 코드 0 = 성공, 1 = 입력·렌더 오류(메시지에 고칠 칸이 적혀 있다), 2 = 자동 검사 실패(PNG·PDF 는 만들어 둔다 — 보고 고치라고).
## 6. 자동 검사 (`out/check.json`, 실패하면 종료 코드 2)
- `ok: true`, `failures: []` 여야 한다. 장마다 잰다:
- 넘침: 칸마다 줄 수 한도(제목 2~4줄, 근거 2~4줄, 숫자 1줄 등). 넘으면 `problem.headline: 4 lines (max 3) → cut 6 characters` 처럼 **어느 칸을 몇 자 줄여야 하는지** 나온다. 가로로 넘치는 긴 낱말·주소도 실패.
- 글자 겹침 0(글자끼리, 글자와 그림·막대), 안전 여백 64px 침범 0.
- 글자 크기 ≥ 28px(1920 기준), 대비 ≥ 4.5:1(48px 이상 3:1).
- 대체 글꼴 0: 칸마다 그 폰트에 없는 글자가 있으면 실패(폰트를 못 불러오면 렌더 자체가 멈춘다).
- 장 수 = PDF 쪽수, 모든 PNG 1920×1080, PDF ≤ 15MB.
- 검사가 실패하면 코드를 고치지 말고 `failures` 를 그대로 사용자에게 보여 주고 **줄인 문장 안을 제안해 확인받는다**(마음대로 줄이지 않는다). 확인받은 문장으로 `props.json` 만 고쳐 다시 실행한다.
- `warnings` 는 실패가 아니지만 사용자에게 그대로 알린다(예: 바탕 색을 스타일 기본값으로 바꿈, 주 색 대비가 낮아 글자에 쓰지 않음, 시장 출처 없음).
## 7. 직접 보는 확인 (반드시, 끝났다고 말하기 전에)
`out/contact.png` 와 `out/slides/` 의 장을 **직접 열어 본다**(이미지 보기 도구). 아래 덱 결함이 하나라도 보이면 보고에 적는다.
- 한 장에 메시지가 둘 이상(제목이 둘이거나 서로 다른 초점 두 개)
- 사실표에 없는 숫자·이름·로고·후기·인용·차트(지어낸 것). 장 이름·고정 문구는 괜찮다
- 장 사이에 서체·여백·위 줄 자리·색 쓰임이 흔들림
- 표지에서 무슨 회사인지 3초 안에 안 읽힘
- 숫자 없이 장식으로만 쓴 차트
- 글자 잘림·겹침·여백 침범·깨진 글자(한글 받침 포함)·대체 글꼴, 대비 부족, 남은 예시 글자, 의미 없는 기호
- 브랜드명·사실표 문장이 입력과 한 글자라도 다름
- 영문 덱의 마스트헤드 브랜드명과 장 이름은 모노 대문자로 표시된다(CSS 표기 — 글자 데이터는 그대로). 결함으로 보지 않는다. 다만 대소문자가 정체성인 브랜드명(eBay·iFixit 처럼)이면 사용자에게 알린다.
## 8. 보고
결과 폴더 경로(`out/deck.pdf`, `out/slides/`, `out/contact.png`), 만든 장 목록과 뺀 장(그 이유 = 비운 칸), `check.json` 의 `failures`·`warnings`, 직접 본 확인 결과(위 결함 목록 기준), 걸린 시간, 고친 것이 있으면 무엇을 왜. 사실표 초안을 썼다면 사용자가 확인한 칸과 근거 파일.
판정 기준(Duchamp 출시 검사와 같다): 결함 0 + 위계·브랜드 맞춤·레퍼런스 충실도·완성도·목적(투자자가 문제 → 해결 → 제품 → 근거 → 요청을 빨리 이해) 다섯 항목. 편집 가능한 PPTX 는 만들지 않는다(글자 배치를 브라우저에서 재서 정하므로 PPTX 로 옮기면 같은 결과를 보장하지 못한다 — PDF·PNG 만).
폰트(전부 SIL OFL 1.1, 상업 사용 가능): Noto Serif KR 500(제목·숫자, @fontsource-variable/noto-serif-kr — 라틴·한글 명조), Pretendard Variable(본문, duchamp.app 자가 호스팅), IBM Plex Mono 400(라벨·쪽수)
예시 · Fernway (영어)
예시 회사(가상) · 수치는 예시 수치 — 실제 회사·실적이 아닙니다. 예시 회사 · 제품 화면은 코드로 그린 목업입니다.
예시 · 모아장부 (한국어)
예시 회사(가상) · 수치는 예시 수치 — 실제 회사·실적이 아닙니다.
예시 PDF 받기 (10쪽)사실이 적을 때 · Tidewell
시장·견인·왜 지금·가격·금액·스크린샷이 없으면 그 장을 빼고 장 7개로 만듭니다. 요청 장은 금액 대신 함께할 사람을 찾는다는 문장과 연락처로 끝납니다.

예시 회사(가상) · 수치는 예시 수치 — 실제 회사·실적이 아닙니다.
예시 PDF 받기 (7쪽)만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요. 빈칸은 비워 둔 채여도 괜찮아요.
- 붙여 넣기
내 프로젝트 레포를 연 Codex·Claude Code 에 붙여 넣으세요. 에이전트가 레포를 읽어 덱 사실표 초안을 만들고 먼저 확인받아요.
- 확인
out/contact.png 로 장 전체를 한눈에 보고, 숫자·이름이 내가 준 것과 같은지 확인하세요. deck.pdf 를 그대로 보내면 됩니다.
빈칸 채우는 법
- [브랜드명]
- 덱 위 줄·표지에 그대로 들어갑니다. 한 글자도 바꾸지 않습니다.
- [브랜드 색]
- 주 색, 바탕 색 HEX 두 개. 비우면 스타일 기본색으로 그립니다.
- [주제]
- 무엇을 누구에게 — 한 문장. 표지의 큰 줄로 그대로 씁니다.
- [넣을 글자]
- 덱 사실표(문제·해결·제품·시장·견인·팀·요청…). 모르는 칸은 비워 두면 그 장이 빠집니다.
- 로고
- 선택. Duchamp 로고 키트의 lockup-horizontal.svg 를 그대로 받습니다.
- 스크린샷
- 선택. 실제 제품 화면 1~2장. 없으면 제품 장은 기능 칸으로 그립니다.
알아 둘 것
- 짙은 바탕 전용 — 밝은 바탕 색은 쓰지 않고 경고합니다. 주 색이 어두우면 같은 색상의 밝은 틴트로 올려 씁니다.
- 영문 덱의 마스트헤드 브랜드명·장 이름은 모노 대문자로 표시됩니다. 대소문자가 정체성인 이름이면 에이전트가 알려 줍니다.
- 숫자(시장·견인·가격·금액)는 내가 준 것만 씁니다. 없으면 그 장이 빠집니다 — 지어내지 않습니다.
- 글자가 칸을 넘치면 코드가 줄이지 않고 몇 자를 줄여야 하는지 알려 줍니다.
- PPTX 는 만들지 않습니다(줄바꿈을 브라우저에서 재서 정하는 방식이라 PowerPoint 에서 넘침 0 을 보장할 수 없어요).



















