← 영상
영상
유리 뒤 글자
코드 렌더 · 같은 결과
리브 유리 뒤에서 줄무늬 형체로 올라온 헤드라인이, 유리 골이 펴지며 초점이 맞듯 선명해집니다.
가상 브랜드 무늬 공방로 레시피를 실제 실행해 렌더한 영상입니다. 소리는 없습니다.
레시피 보기100,859자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 영상 「유리 뒤 글자」 9:16 · 헤드라인 1~2줄 + 부제 + 브랜드 칩 · 약 8~9초 (조명 → 유리 뒤 형체 → 선명 → 부제·칩 → 맺음)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. **AI 영상 생성을 쓰지 않는다.** 판도 필요 없다. 무대·빛·유리·글자·움직임을 코드(React + three.js + HyperFrames)가 실제 폰트로 그려 세로 MP4 한 편으로 렌더한다.
- 1080×1920 · **60fps · 서브프레임 모션 블러**(한 프레임 안에서 시간을 조금씩 비켜 그린 4장 — 글자가 한 프레임에 20px 넘게 움직이는 빠른 구간은 한 장 간격이 4px 이하가 되게 최대 16장 — 을 WebGL 렌더타깃에 평균. 움직일 때만 번지고 멈추면 칼같이 선명) · **무음**(음악은 편집 앱에서)
- **무대**: 어두운 전시실 벽에 천장 스팟 하나가 켜진다(첫 프레임부터 30% 켜진 채 0.8초 안에 다 밝아진다 — 피드 첫 화면이 빈 검정이 아니다. 위에서 내려오는 따뜻한 빛 웅덩이, 벨벳 결은 빛 안에서만, 벽과 바닥이 만나는 부드러운 접촉 그늘. 차가운 코발트 림은 벽이 아니라 유리 골의 오른쪽 면에만 맺히고 유리와 함께 사라진다 — 포인트색이 차가운 계열일 때만). 글자가 멈춰 있는 동안 배경은 조용하다.
- **레퍼런스 문법**(실제 행사 예고 영상을 프레임 단위로 뜯어 잰 값 + 리브 유리 셰이더의 파라미터 어휘): 0.8초부터 헤드라인이 **리브(플루티드) 유리 뒤**에서 굴절된 세로 줄무늬 형체로 아래에서 빨리 올라와 **완전히 멈추고**(읽힐 듯 말 듯), 유리의 골이 1.8초 동안 위에서 아래로 펴지며(줄마다 ease-in-out) 글자가 초점이 맞듯 선명해져 다시 멈춘다(약 1.5~1.8초). 부제와 브랜드 칩이 같은 곡선으로 올라오며 헤드라인을 묶음 자리로 밀어 올리고, 맺음 2.1초(앞 0.6초는 조명 사건, 뒤 1.5초는 부제·브랜드를 읽는 완전 정지). 들어옴은 지수 감속(30fps 한 프레임마다 남은 거리 ×0.85), 오버슈트 0, 페이드·하드컷 없음.
- 코드 검사: 입력 빈칸·색·길이 → 유리 뒤·선명(시작·가운데·끝)·부제·맺음 프레임에서 **배경을 따로 그려 픽셀을 읽고** 글자 뒤 밝기로 대비(4.5 이상)·**브랜드 칩 글자와 알약 바탕의 대비(실측 4.5 이상)**·글자 구역 평균 밝기·국소 표준편차·모서리 밝기(낙차)·글자 정확성·안전 영역·겹침·유리 상태를 잰다 → MP4 에서 **움직임 프로파일**(멈춤 40~65%, 느린 흐름 40% 이하)·**비트별 정지 0.8~2.5초**·**튀는 프레임 0**·규격·무음·**키프레임 1개**(정지 화면 중간에 화질이 한 프레임 바뀌는 깜빡임이 없게 영상 전체를 한 GOP 로 인코딩).
원본 템플릿: https://duchamp.app/readymade/videos/glass-type-reveal-video
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름) # 마지막에 올라오는 칩 안 글자. 24자 이하
브랜드 색: (내 브랜드 색 — 바탕 · 글자 · 포인트) # "밝은 색, 어두운 색, 포인트색" HEX 3개. 어두운 색이 무대(벽), 밝은 색이 글자, 포인트색이 칩과 유리 틴트. 비우면 #EDE8DE, #120F0E, #2B30AE
주제: (내 브랜드에 맞는 주제 하나) # 부제 한 줄(헤드라인 아래 작은 글자). 예: 가을 전시 10.24 — 11.09 / Autumn pieces · from October 24. 없으면 비운다
넣을 글자: (주제에 맞춰 분량 상한 안에서 컷별 글자를 지어 넣는다) # 헤드라인 1~2줄. 줄은 " | " 로 나눈다(안 나누면 9자 이상일 때 코드가 뜻 단위로 두 줄). 예: 유리 너머의 | 계절 / Quiet things | last longer
출력: MP4 # MP4(끝까지, 멈추지 않음) | 계획 먼저(비트맵·정지 화면을 먼저 보여 주고 승인 뒤 MP4) | 계획만
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 브랜드명·헤드라인·부제는 한 글자도 바꾸지 않는다(띄어쓰기·기호 포함). 날짜·장소 같은 사실은 사용자가 준 것만 쓰고, 주지 않은 칸은 비워 둔다(지어 넣지 않는다).
- 크기·줄바꿈·자리·정지 길이는 코드가 정한다(§3). 헤드라인이 길어 코드가 실패하면(`big-small`·`too-long`) 줄인 안을 사용자에게 물어본다.
- **AI 영상 생성은 쓰지 않는다.** 이미지→영상·글자→영상 생성 도구(Seedance·Kling·Sora·Veo·Runway·Higgsfield 영상 등)를 부르지 않는다. 빛·유리·굴절·움직임은 전부 아래 코드가 만든다.
- 음악·효과음·음성은 넣지 않는다. 결과 MP4 는 무음이다(음악은 사용자가 인스타그램·캡컷 같은 편집 앱에서 붙인다. 보고에 이 한 줄을 쓴다).
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.json` 뿐이다. 코드가 실패하면 오류 메시지를 그대로 보고하고, 고쳐야 했다면 무엇을 왜 고쳤는지 보고에 쓴다. 다른 폴더에서 비슷한 코드를 찾아 쓰지 않는다. 이 문서의 코드만 쓴다.
- 비밀 값·.env 파일은 읽지 않는다.
- 이 작업 밖의 일(프로젝트 관리 카드·메모 갱신, 배포, 다른 브라우저 자동화 도구, 사용자에게 묻지 않은 유료 생성 API·크레딧 사용)은 하지 않는다.
- 필요한 것: Node 18 이상, 인터넷(`npm install` 때만 — 폰트(Pretendard)도 이때 받으므로 렌더는 인터넷 없이 된다). 설치는 이 폴더 안에서만 한다(`npm install`: 렌더러 HyperFrames(Apache 2.0)·esbuild·React·ffmpeg·**three.js(MIT, 약 22MB)**). 첫 실행 때 HyperFrames 가 헤드리스 Chrome(약 190MB)을 이 폴더의 `.render/home` 에 받는다.
- 이 레시피는 **WebGL(three.js)** 로 그린다. 맥(Metal)·윈도(D3D11)·GPU 가 있는 리눅스에서는 HyperFrames 가 하드웨어 GPU 를 자동으로 잡는다. GPU 가 없는 환경(CI·Docker·서버)에서는 소프트웨어 WebGL(SwiftShader)로 느리게 돈다(추정 수십 분) — 멈춘 것이 아니니 기다린다.
- **샌드박스 안에서 실행 중이면(Codex 기본 모드 등)**: `npm install` 은 네트워크가, `node render.mjs` 는 헤드리스 Chrome 실행이 필요하다. macOS 샌드박스에서는 Chrome 이 `bootstrap_check_in ... Permission denied (1100)` / `Failed to launch the browser process` 로 막힌다. 이 오류가 나면 우회하지 말고 바로 사용자에게 "샌드박스 밖 실행(권한 상승) 승인"을 요청해 같은 명령을 다시 실행한다. 승인을 받을 수 없으면 거기서 멈추고, 사용자가 일반 터미널에서 실행할 명령 한 줄(`cd <폴더> && node render.mjs mp4`)을 보고한다.
- `npm install` 이 `EPERM`(홈의 npm 캐시 권한)으로 실패하면 `npm install --no-audit --no-fund --cache .npm-cache` 로 이 폴더 안 캐시를 쓴다.
## 2. 스튜디오 패키지 (코드가 이대로 구현돼 있다 — 고치지 않는다. 확인·보고의 기준)
**(a) 레퍼런스 문법 — 타이밍표 핵심 수치**
| 항목 | 값 |
|---|---|
| 조명 | 첫 프레임부터 스팟이 30% 켜져 있고 0~0.8초에 100%(ease-in-out). 빛 웅덩이가 300px 내려와 자리를 잡는다(×0.85/f). 키라이트 최고 밝기는 무대색→키색의 74%(헤드라인이 빛 웅덩이의 가장 밝은 곳과 겹쳐도 대비 여유가 남게). 켜진 뒤에는 ±1.2%·5초 숨쉬기뿐(프로파일에 잡히지 않는 정도). 맺음 시작 0.6초 동안 키라이트가 글자 묶음으로 12% 모이고 5% 밝아진다 — 배경의 '사건'은 이 둘뿐 |
| 들어옴 | 0.8초부터. 지수 감속: 30fps 한 프레임마다 남은 거리 ×0.85(τ 0.205초), 오버슈트 0. 헤드라인 648px·부제와 칩 520px 아래에서. 투명도 0.3초 |
| 유리 | 리브 24골(화면 폭 기준), 반원통 굴절(굴절률 1.52) + 색수차 + 가로 블러(9탭, 반폭 8px) + 약한 세로 번짐(±4px — 글자 조각의 가로 경계가 칼처럼 잘리지 않게) + 골 그늘·면 음영 + 세로 하이라이트 줄(빛 웅덩이 안에서 밝다) + 오른쪽 면의 차가운 림. 골은 움직이지 않는다(유리 뒤 정지가 정지로 재지게) |
| 펴짐 | 2.7초부터 1.8초. 위에서 아래로 훑고(0.2 비율 시차), 줄마다 1.44초 ease-in-out(smoothstep). 굴절·번짐·색수차·그늘·하이라이트·림이 함께 0 이 된다(전환은 이것 하나 — 페이드·컷 없음). 글자 영역은 약 0.8초에 걸쳐 10%→90% 선명해진다(윗줄이 먼저) |
| 정지 | 유리 뒤 약 1.0초 · 선명 0.75초 + 글자당 0.03초(1.0~1.35초 — 펴짐의 느린 끝을 더하면 실제 약 1.5~1.8초) · 부제·칩은 안착하면 바로 맺음 · 맺음 2.1초(앞 0.6초는 조명 사건, 뒤 1.5초 완전 정지). 경계는 0.25초 격자 |
| 구도 | 가운데 정렬. 헤드라인(Pretendard 800, 최대 176px·영문 196px, 폭 820px 안 — 좌우 130px 이상 비워 릴스·쇼츠 오른쪽 버튼 열과 떨어진다)·부제(500, 52px)·칩(600, 39px, 포인트색 알약) 세 덩이를 안전 영역 가운데보다 50px 아래(y 930 — 바닥 쪽 빈 곳과 무게를 맞춤)에 한 묶음으로 둔다. 스팟 중심은 묶음 위(y 422)에 있어 글자는 빛 웅덩이의 아래쪽 조용한 띠에 선다 |
| 빛 | 키라이트 하나(위, 따뜻한 회색 #A69C98) + 바닥 바운스(#817065) + 차가운 림(#2A3CFF, 세기 0.13 — 유리 골의 오른쪽 면에만, 화면 오른쪽 절반, 포인트색이 차가운 계열일 때만 · 면적 5% 이하). 벽에 붙은 림 띠는 없다. 글자 구역 평균 밝기 Y 30~95, 모서리 Y ≤ 24(무대색이 그보다 밝으면 무대색 밝기 + 4)를 코드가 잰다 |
| 프로파일 | 레퍼런스(행사 예고) 멈춤/느린 흐름/큰 움직임 = 53/15/32%. 이 코드의 기준 40~65 / ≤40 / 나머지(빛·작은 글자의 감속 꼬리가 '느린 흐름'으로 잡힌다) |
**(b) 상태·비트 표** (실제 시각은 `out/beats.md`)
| 비트 | 보는 사람이 아는 것 | 진입 | 퇴장 | 존재 이유 |
|---|---|---|---|---|
| 1 조명 | 어두운 벽에 스팟이 켜진다(무대) | 스팟 0→1, 웅덩이가 내려와 자리를 잡음 | 남음 | 빛이 있는 곳 |
| 2 유리 뒤 | 무언가 쓰여 있다 — 글자 같은데 읽히지 않는다 | 줄무늬 형체가 아래에서 감속 상승 | 유리가 펴짐 | 궁금하게 |
| 3 선명 | 헤드라인 | 골이 위→아래로 펴지며 글자가 선명해짐 | 남음 | 말하려는 것 |
| 4 부제·칩 | 부제 + 누구(브랜드) | 같은 곡선으로 상승, 칩은 0.1초 늦게 | 남음 | 사실과 이름 |
| 5 맺음 | 전부 | 키라이트가 글자 묶음으로 모임(0.6초) | 끝(정지 1.5초) | 기억할 시간 — 부제·브랜드까지 읽힌다 |
**(c) 물체 종류별 모션 규칙**
| 물체 | 규칙 |
|---|---|
| 헤드라인(큰 글자) | 한 묶음으로 빨리 들어와 완전히 정지. 줄마다 따로 움직이지 않는다. 넘침·스프링 없음 |
| 부제·칩 | 같은 곡선, 칩만 0.1초 늦게. 정지 뒤 움직이지 않는다 |
| 유리 | 펴지는 1.8초 말고는 움직이지 않는다. 펴진 뒤에는 흔적이 없다 |
| 빛 | 켜지는 0.8초(내려와 자리 잡음)와 맺음 0.6초(모임)에만 움직인다. 글자가 멈춘 동안 배경 변화 ≤0.1 |
| 카메라·흔들림·페이드·하드컷·입자·움직이는 그레인 | 없음(출력 디더는 화소 자리에 고정된 ±1/255 TPDF — 움직이지 않는다) |
**(d) 검수 체크리스트** (`out/still-*.png`·`out/contact.png` 를 직접 보고)
1. 헤드라인·부제·브랜드명이 입력과 글자 하나까지 같다(받침·숫자·기호·띄어쓰기). 브랜드 칩 글자가 알약 바탕 위에서 또렷하다(`check.json` 의 칩 `contrast` 4.5 이상).
2. 유리 뒤 정지(`still-1-glass`)에서 글자가 **세로 줄무늬 형체**로 보이고 읽히지 않는다. 펴지는 중(`still-2-clearing`)은 반쯤 읽힌다. 선명(`still-3-sharp`)·끝(`still-4-end`)은 또렷하다.
3. 빛은 위에서 온다 — 벽의 가장 밝은 곳이 헤드라인 위에 있고, 모서리는 거의 검다. 글자는 빛 웅덩이의 아래쪽에 있고 밝은 중심 위에 올라타지 않는다.
4. 컨택트 시트에서 유리 뒤 형체가 3~4칸, 선명한 헤드라인이 3~4칸, 부제·칩이 있는 화면이 4칸 이상 이어진다(= 읽을 정지).
5. 벽(화면 가장자리)에 코발트 띠가 없다. 차가운 림은 유리 골의 오른쪽 면에만 보이고(포인트색이 차가운 계열일 때) 유리가 펴지면 함께 사라진다. 선명 이후 글자 구역을 가로지르는 무늬·줄·망점이 없다.
**(e) 게이트 — 전체 렌더 전에 승인받기 (`출력` 칸이 정한다)**
- `출력: 계획 먼저` 일 때만: `node render.mjs stills` 로 비트맵(`out/beats.md`)·정지 화면 4장(`out/still-1-glass.png` · `2-clearing` · `3-sharp` · `4-end`)·컨택트 시트를 만들고, 사용자에게 보여 승인을 받은 뒤 `node render.mjs mp4` 를 실행한다.
- `출력: MP4`(기본)이면 **게이트를 건너뛴다 — 묻거나 멈추지 말고** `node render.mjs mp4` 까지 실행한다.
## 3. 범용 입력 규칙 (코드가 처리 — 알고만 있으면 된다)
- **줄 나눔**: 헤드라인은 ` | ` 로 나눈 그대로. 안 나눴고 9자(빈칸 빼고) 이상이면 두 줄로 고르게 나눈다. 3줄 이상이면 `too-long` 으로 멈춘다.
- **크기**: 헤드라인은 두 줄 같은 크기로, 긴 줄이 820px 에 맞게(한글 최대 176px, 영문 196px, 자간 -2%). 110px 아래로 내려가야 하면 `big-small` 로 멈춘다. 부제는 52px 에서 폭 810px 안으로, 칩은 39px 고정.
- **정지 길이 = 글자 수 비례**: 선명 0.75초 + 글자당 0.03초(1.0~1.35초, 펴짐의 느린 끝이 더해진다). 긴 글이 더 오래 멈춘다. 부제·칩이 안착하면 맺음 2.1초. 경계는 0.25초 격자.
- **색**: 두 색 중 어두운 쪽이 무대(벽), 밝은 쪽이 글자. 두 색 대비가 7 미만이면 `contrast` 로 멈춘다. 부제는 글자색을 무대색 쪽으로 25% 섞되 대비 5 이상. 칩은 포인트색 알약 + 글자/무대색 중 더 잘 보이는 쪽. 그래도 대비가 5.0(작은 글자 기준 4.5 + 압축·화면 여유) 아래면 알약 바탕을 글자 반대쪽으로 조금씩 섞는다(밝은 글자 → 무대색 쪽으로 어둡게, 어두운 글자 → 글자색 쪽으로 밝게) — 포인트색이 살짝 어둡거나 밝아질 수 있고, 실제 쓴 색은 `check.json` 의 `video.palette.chip`. 키라이트·림·바운스 색은 고정(전시실 조명 브리프).
- **빛 검사**: 선명·부제·맺음 프레임에서 배경만 따로 그려 잰다 — 글자 뒤 대비 4.5 미만이면 `contrast`, 글자 구역(안전 영역) 평균 Y 가 30~95 밖이면 `zone-light`, 135px 축소 기준 국소 표준편차 12 초과면 `zone-busy`, 모서리 Y 가 24(무대색이 그보다 밝으면 무대색 밝기 + 4)를 넘으면 `corner-light`. 부제·맺음 프레임에서 글자를 그린 판을 읽어 브랜드 칩 글자와 알약 바탕의 대비가 4.5 미만이면 `chip-contrast`. 뒤의 넷은 코드 문제라 그대로 보고한다.
## 4. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명>",
"headline": "<헤드라인 — 줄은 | 로>",
"line": "<부제 또는 null>",
"colors": {"paper": "<밝은 색>", "ink": "<어두운 색>", "accent": "<포인트색>"}
}
```
- 글자는 입력 그대로(` | `·` — `·`·` 도 그대로). 부제가 없으면 `null`.
- 길이·시각·크기·골 수는 넣지 않는다(코드가 정한다. 골 수를 바꾸고 싶을 때만 `"flutes": 12~48`).
## 5. 파일 구조와 코드 (현재 폴더에 만든다. 코드는 그대로 저장)
```
package.json render.mjs hf.mjs fonts.json props.json src/index.tsx src/duchamp-frame.tsx
out/video.mp4 out/beats.md out/still-*.png out/contact.png out/check.json
```
`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",
"three": "0.186.1"
},
"scripts": {
"postinstall": "node hf.mjs fonts"
}
}
```
`render.mjs`
```js
// 모션 영상 공용 렌더러 — 비율(1:1 · 16:9 · 4:5 · 9:16)과 검사 기준을 컴포지션이 정한다. node render.mjs stills | mp4
// 컴포지션의 calculateMetadata 가 크기(format)·길이와 함께 props 에 다음을 실어 보낸다(이 파일은 그걸 읽기만 한다):
// input [{code, hint}] 입력 오류 — 하나라도 있으면 렌더 전에 멈춘다
// files ['plates/x.png', …] public/ 안에 있어야 하는 파일
// beats [{label, t0, t1, knows, enter, hold, exit}] 상태·비트 표 → out/beats.md (프로파일의 장면 정지 창도 이 구간)
// checks [{frame, scene, at}] 검사 프레임 — 컴포지션이 그 프레임에서 console.log('DUCHAMP_CHECK ' + {frame, scene, at, over, hint})
// stills [{frame, name}] 대표 정지 화면 → out/still-<name>.png
// profile {still: [최소, 최대], slowMax, hold: [최소, 최대] | null} 움직임 프로파일 기준(장르마다 다르다)
// mp4 = 60fps · 소리 없음 · 크기는 컴포지션 그대로. 결과: out/video.mp4 out/beats.md out/still-*.png out/contact.png out/check.json
// MP4 인코딩(이 레시피): HyperFrames 인코더는 250프레임마다 키프레임을 넣어 정지 화면에서 한 프레임 깜빡임(펄스)이 보인다 →
// 같은 렌더를 무손실(crf 0) 중간 파일로 받고, x264 로 다시 한 번만 인코딩한다 — 키프레임 1개(GOP = 전체 프레임 수, scenecut 0)·crf 13·preset slow·tune grain(디더 보존)·BT.709 tv·+faststart·무음. 중간 파일은 지운다
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import {ff, meta, render} from './hf.mjs';
const mode = process.argv[2] ?? 'mp4';
const props = JSON.parse(fs.readFileSync('props.json', 'utf8'));
fs.rmSync('out', {recursive: true, force: true});
fs.mkdirSync('out/_b', {recursive: true});
const over = [], hints = [];
const check = {ok: false, overflow: [], hint: null};
const save = () => { check.overflow = [...new Set(over)]; check.ok = check.overflow.length === 0; check.hint = [...new Set(hints)].join(' / ') || null; fs.writeFileSync('out/check.json', JSON.stringify(check, null, 2)); };
const stop = async () => { save(); console.log('CHECK', check.ok ? 'ok' : 'FAIL ' + JSON.stringify(check.overflow), '→ out/check.json'); process.exit(check.ok ? 0 : 2); };
const readPng = (file) => {
const b = fs.readFileSync(file); let p = 8, w = 0, h = 0, ct = 0; const idat = [];
while (p < b.length) { const len = b.readUInt32BE(p), type = b.toString('ascii', p + 4, p + 8), d = b.subarray(p + 8, p + 8 + len); if (type === 'IHDR') { w = d.readUInt32BE(0); h = d.readUInt32BE(4); ct = d[9]; } if (type === 'IDAT') idat.push(d); if (type === 'IEND') break; p += 12 + len; }
const bpp = {0: 1, 2: 3, 4: 2, 6: 4}[ct], raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * bpp, px = Buffer.alloc(h * stride); let prev = Buffer.alloc(stride);
for (let y = 0; y < h; y++) { const f = raw[y * (stride + 1)], line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), out = px.subarray(y * stride, (y + 1) * stride);
for (let x = 0; x < stride; x++) { const a = x >= bpp ? out[x - bpp] : 0, up = prev[x], c = x >= bpp ? prev[x - bpp] : 0; let v = line[x];
if (f === 1) v += a; else if (f === 2) v += up; else if (f === 3) v += (a + up) >> 1; else if (f === 4) { const q = a + up - c, pa = Math.abs(q - a), pb = Math.abs(q - up), pc = Math.abs(q - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? up : c; }
out[x] = v & 255; } prev = out; }
const rgb = Buffer.alloc(w * h * 3); for (let i = 0; i < w * h; i++) for (let c = 0; c < 3; c++) rgb[i * 3 + c] = px[i * bpp + (bpp >= 3 ? c : 0)];
return {w, h, rgb};
};
const CRC = Array.from({length: 256}, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const chunk = (type, data) => { const t = Buffer.from(type), l = Buffer.alloc(4), c = Buffer.alloc(4); l.writeUInt32BE(data.length); let x = 0xffffffff; for (const v of Buffer.concat([t, data])) x = CRC[(x ^ v) & 255] ^ (x >>> 8); c.writeUInt32BE((x ^ 0xffffffff) >>> 0); return Buffer.concat([l, t, data, c]); };
const writePng = (file, w, h, rgb) => { const ih = Buffer.alloc(13); ih.writeUInt32BE(w); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 2; const raw = Buffer.alloc((w * 3 + 1) * h); for (let y = 0; y < h; y++) rgb.copy(raw, y * (w * 3 + 1) + 1, y * w * 3, (y + 1) * w * 3);
fs.writeFileSync(file, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])); };
const t0 = Date.now(), sec = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
const comp = await meta(props);
const P = comp.props, fps = comp.fps, size = `${comp.width}x${comp.height}`;
for (const e of P.input ?? []) { over.push(e.code ?? 'input'); hints.push(e.hint); }
for (const f of P.files ?? []) if (!fs.existsSync(path.join('public', f))) { over.push('file-missing'); hints.push(`public/${f} 파일이 없음`); }
if (over.length) await stop();
const checks = [];
const onBrowserLog = (l) => { if (l.text.startsWith('DUCHAMP_CHECK ')) checks.push(JSON.parse(l.text.slice(14))); else if (l.type === 'error') console.error('[browser]', l.text); };
const stills = async (name, list) => { const r = await render({name, props, m: comp, format: 'png', frames: list.map((x) => x[0]), out: list.map((x) => x[1])}); if (r.fail) { console.error('렌더 실패:', r.fail); process.exit(1); } r.logs.forEach((text) => onBrowserLog({text, type: 'log'})); };
const beats = P.beats ?? [], b = (x) => (x / 0.5).toFixed(1).replace(/\.0$/, ''), end = comp.durationInFrames / fps;
fs.writeFileSync('out/beats.md', `# 비트맵 · ${end.toFixed(2)}초 · ${fps}fps · ${size}${P.palette ? ` · 바탕 ${P.palette.bg} 글자 ${P.palette.fg} 포인트 ${P.palette.accent}` : ''}\n\n| 비트 | 시간 | 보는 사람이 아는 것 | 진입 | 정지 | 퇴장 |\n|---|---|---|---|---|---|\n${beats.map((x, i) => `| ${i + 1} ${x.label} | ${x.t0.toFixed(2)}–${x.t1.toFixed(2)}초 (비트 ${b(x.t0)}–${b(x.t1)}) | ${x.knows} | ${x.enter} | ${x.hold} | ${x.exit} |`).join('\n')}\n`);
console.log(fs.readFileSync('out/beats.md', 'utf8'));
const cf = P.checks ?? [];
await stills('checks', [...cf.map((c, i) => [c.frame, `out/_b/x${i}.png`]), ...(P.stills ?? []).map((s) => [s.frame, `out/still-${s.name}.png`])]);
check.video = {seconds: +end.toFixed(3), fps, size, format: P.format ?? null, palette: P.palette ?? null};
check.scenes = cf.map((c) => checks.find((x) => x.frame === c.frame) ?? {...c, over: ['no-check'], hint: ['검사 기록 없음']});
for (const c of check.scenes) { over.push(...c.over); hints.push(...c.hint.map((h) => `${c.scene}(${c.at}): ${h}`)); }
console.log(`정지 화면 ${(P.stills ?? []).map((s) => `out/still-${s.name}.png`).join(' ')} ${sec()}`);
// 비트(0.5초)마다 한 장 → 짧은 변 216 으로 줄여 컨택트 시트에
const marks = []; for (let x = 0.5; x <= end + 1e-6; x += 0.5) marks.push(Math.min(Math.round(x * fps), comp.durationInFrames - 1));
await stills('beats', marks.map((f, i) => [f, `out/_b/full-${String(i).padStart(3, '0')}.png`]));
const k = 216 / Math.min(comp.width, comp.height);
for (let i = 0; i < marks.length; i++) { const n = String(i).padStart(3, '0'); ff(['-v', 'error', '-y', '-i', path.resolve(`out/_b/full-${n}.png`), '-vf', `scale=${Math.round(comp.width * k / 2) * 2}:${Math.round(comp.height * k / 2) * 2}:flags=area`, path.resolve(`out/_b/${n}.png`)]); }
{ const tiles = marks.map((_, i) => readPng(`out/_b/${String(i).padStart(3, '0')}.png`)), tw = tiles[0].w, th = tiles[0].h, C = comp.width > comp.height ? 5 : 8, G = 6, R = Math.ceil(tiles.length / C);
const SW = C * tw + (C + 1) * G, SH = R * th + (R + 1) * G, s = Buffer.alloc(SW * SH * 3, 128);
tiles.forEach((tl, i) => { const ox = G + (i % C) * (tw + G), oy = G + Math.floor(i / C) * (th + G); for (let y = 0; y < th; y++) tl.rgb.copy(s, ((oy + y) * SW + ox) * 3, y * tw * 3, (y + 1) * tw * 3); });
writePng('out/contact.png', SW, SH, s); }
console.log(`컨택트 시트 out/contact.png (비트마다 1장, ${marks.length}장) ${sec()}`);
save();
if (!check.ok || mode !== 'mp4') { fs.rmSync('out/_b', {recursive: true, force: true}); await stop(); }
let lastP = -1;
const v = await render({name: 'video', props, m: comp, format: 'mp4', crf: 0, out: 'out/_lossless.mp4',
onProgress: (q0) => { const q = Math.floor(q0 * 10); if (q !== lastP) { lastP = q; console.log(`MP4 ${q * 10}% ${sec()}`); } }});
if (v.fail) { fs.rmSync('out/_lossless.mp4', {force: true}); console.error('렌더 실패:', v.fail); process.exit(1); }
const N = comp.durationInFrames;
const enc = ff(['-v', 'error', '-y', '-i', path.resolve('out/_lossless.mp4'), '-map', '0:v:0', '-an', '-c:v', 'libx264', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', '13', '-tune', 'grain',
'-x264-params', `keyint=${N}:min-keyint=${N}:scenecut=0:aq-mode=3`, '-r', String(fps), '-color_primaries', 'bt709', '-color_trc', 'bt709', '-colorspace', 'bt709', '-color_range', 'tv', '-movflags', '+faststart', path.resolve('out/video.mp4')]);
fs.rmSync('out/_lossless.mp4', {force: true});
if (enc.status !== 0) { console.error('MP4 인코딩 실패:', (enc.stderr ?? '').slice(-600)); process.exit(1); }
console.log(`MP4 인코딩(키프레임 1개) ${sec()}`);
// 움직임 프로파일 — 30fps 로 솎고 가로 96 회색으로 줄여 이웃 프레임 평균 차: < 0.3 멈춤 · 0.3~2 느린 흐름 · ≥ 2 큰 움직임
const PH = Math.round(96 * comp.height / comp.width / 2) * 2, NP = 96 * PH;
fs.mkdirSync('out/_m'); ff(['-v', 'error', '-i', path.resolve('out/video.mp4'), '-vf', `scale=96:${PH}:flags=area,format=gray`, path.resolve('out/_m/%05d.png')]);
const fr = fs.readdirSync('out/_m').sort().filter((_, i) => i % Math.round(fps / 30) === 0).map((f) => readPng(path.join('out/_m', f)).rgb);
const d = []; for (let i = 1; i < fr.length; i++) { let s = 0; for (let q = 0; q < NP; q++) s += Math.abs(fr[i][q * 3] - fr[i - 1][q * 3]); d.push(s / NP); }
if (d.length < 30) { over.push('profile'); hints.push('MP4 프레임을 다시 읽지 못함'); }
const pct = (f) => +(100 * d.filter(f).length / d.length).toFixed(1);
const prof = {still: pct((x) => x < 0.3), slow: pct((x) => x >= 0.3 && x < 2), big: pct((x) => x >= 2), pops: 0, scenes: []};
for (let i = 1; i < d.length - 1; i++) if (d[i] > 1 && d[i] > 3 * Math.max(d[i - 1], d[i + 1])) { prof.pops++; (prof.popAt ??= []).push(+((i + 1) / 30).toFixed(2)); }
for (const x of beats) { let run = 0, best = 0; for (let i = Math.floor(x.t0 * 30); i < Math.min(d.length, Math.floor(x.t1 * 30)); i++) { run = d[i] < 0.3 ? run + 1 : 0; best = Math.max(best, run); } prof.scenes.push(+(best / 30).toFixed(2)); }
check.profile = prof;
const pf = P.profile ?? {};
if (pf.still && (prof.still < pf.still[0] || prof.still > pf.still[1])) { over.push('profile'); hints.push(`멈춤 ${prof.still}% — ${pf.still[0]}~${pf.still[1]}% 밖(코드 문제)`); }
if (pf.slowMax != null && prof.slow > pf.slowMax) { over.push('profile'); hints.push(`느린 흐름 ${prof.slow}% > ${pf.slowMax}%(코드 문제)`); }
if (pf.hold) beats.forEach((x, i) => { if (x.holdCheck !== false && (prof.scenes[i] < pf.hold[0] || prof.scenes[i] > pf.hold[1])) { over.push('hold'); hints.push(`비트 ${i + 1} ${x.label} 정지 ${prof.scenes[i]}초 — ${pf.hold[0]}~${pf.hold[1]}초 밖`); } });
if (prof.pops) { over.push('pop'); hints.push(`튀는 프레임 ${prof.pops}개(${prof.popAt.join(', ')}초) — 코드 문제`); }
const info = ff(['-i', path.resolve('out/video.mp4')]).stderr ?? '', dm = /Duration: (\d+):(\d+):([\d.]+)/.exec(info);
check.mp4 = {file: 'out/video.mp4', seconds: dm ? +dm[1] * 3600 + +dm[2] * 60 + +dm[3] : null, size: /(\d{3,4})x(\d{3,4})/.exec(info)?.[0] ?? null, fps: +(/([\d.]+) fps/.exec(info)?.[1] ?? 0), audio: /Audio:/.test(info)};
check.mp4.kbps = +(/bitrate: (\d+) kb\/s/.exec(info)?.[1] ?? 0);
check.mp4.keyframes = (ff(['-v', 'error', '-skip_frame', 'nokey', '-i', path.resolve('out/video.mp4'), '-map', '0:v:0', '-f', 'framemd5', '-']).stdout ?? '').split('\n').filter((l) => /^\d/.test(l)).length;
if (check.mp4.keyframes !== 1) { over.push('format'); hints.push(`MP4 키프레임 ${check.mp4.keyframes}개 — 1개가 아님(정지 중 깜빡임)`); }
if (check.mp4.audio) { over.push('audio'); hints.push('MP4 에 소리 트랙이 있음'); }
if (check.mp4.size !== size || check.mp4.fps !== 60) { over.push('format'); hints.push(`MP4 ${check.mp4.size} ${check.mp4.fps}fps — ${size} 60fps 가 아님`); }
for (const x of ['out/_b', 'out/_m']) fs.rmSync(x, {recursive: true, force: true});
console.log(`MP4 out/video.mp4 ${check.mp4.seconds}s · ${check.mp4.size} · ${check.mp4.fps}fps · ${(check.mp4.kbps / 1000).toFixed(1)}Mbps · 키프레임 ${check.mp4.keyframes}개 · 소리 ${check.mp4.audio ? '있음(!)' : '없음'} · 멈춤/느린/큰 ${prof.still}/${prof.slow}/${prof.big}% · 비트 정지 ${prof.scenes.join('/')}초 · 튐 ${prof.pops} ${sec()}`);
await stop();
```
`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` (앞부분 = 비율 표·공용 모션 라이브러리, 시간 t 의 순수 함수만)
```tsx
import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {flushSync} from 'react-dom';
import * as THREE from 'three';
import {AbsoluteFill, Composition, cancelRender, continueRender, delayRender, registerRoot, useCurrentFrame, useVideoConfig} from './duchamp-frame';
// 비율 — props.format 하나로 캔버스 크기·안전 영역·크기 단위를 고른다. 같은 내용을 비율마다 따로 구성한다(크롭이 아니다).
// u = 짧은 변 / 1080 — 글자·거리는 1080 기준 px × u. 안전 영역 [왼, 위, 오른, 아래]: 9:16 은 릴스·쇼츠 화면 UI(위 220 · 아래 380)를 피한다.
type Format = '9:16' | '4:5' | '1:1' | '16:9';
const FORMATS: Record<Format, {W: number; H: number; safe: [number, number, number, number]}> = {
'9:16': {W: 1080, H: 1920, safe: [90, 220, 90, 380]},
'4:5': {W: 1080, H: 1350, safe: [80, 72, 80, 72]},
'1:1': {W: 1080, H: 1080, safe: [80, 80, 80, 80]},
'16:9': {W: 1920, H: 1080, safe: [120, 72, 120, 72]},
};
const isFormat = (x: unknown): x is Format => typeof x === 'string' && Object.prototype.hasOwnProperty.call(FORMATS, x);
const frameOf = (f: Format) => {
const {W, H, safe: [l, t, r, b]} = FORMATS[f], w = W - l - r, h = H - t - b;
return {format: f, W, H, u: Math.min(W, H) / 1080, landscape: W > H, safe: {x: l, y: t, w, h, cx: l + w / 2, cy: t + h / 2}};
};
type Frame = ReturnType<typeof frameOf>;
const tauOf = (perFrame: number) => 1 / 29.97 / Math.abs(Math.log(perFrame));
const TAU_IN = tauOf(0.85), TAU_OUT = tauOf(1.23);
const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const inOff = (t: number, t0: number, D: number, tau = TAU_IN) => Math.min(D * Math.exp(-(t - t0) / tau), D * 40);
const outOff = (t: number, X: number, A: number, tau = TAU_OUT) => (t <= X ? 0 : -Math.min(A * (Math.exp((t - X) / tau) - 1), 6000));
const matchA = (D: number, gap: number) => D * (TAU_OUT / TAU_IN) * Math.exp(-gap / TAU_OUT);
const scene = (t: number, tIn: number, X: number, D: number, gap = 0.6) => inOff(t, tIn, D) + outOff(t, X, matchA(D, gap));
const spring = (t: number, t0: number, f = 2.2, z = 0.75) => {
const x = t - t0; if (x <= 0) return 0;
const w = 2 * Math.PI * f;
if (z >= 1) return 1 - Math.exp(-w * x) * (1 + w * x);
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * x) * (Math.cos(wd * x) + (z * w / wd) * Math.sin(wd * x));
};
const springs = (t: number, base: number, steps: {t0: number; to: number; f?: number; z?: number}[]) => {
let v = base, prev = base;
for (const s of steps) { v += (s.to - prev) * spring(t, s.t0, s.f, s.z); prev = s.to; }
return v;
};
const bez = (x1: number, y1: number, x2: number, y2: number) => (x: number) => {
if (x <= 0) return 0; if (x >= 1) return 1;
let lo = 0, hi = 1, s = 0.5;
for (let i = 0; i < 30; i++) { s = (lo + hi) / 2; const bx = 3 * (1 - s) ** 2 * s * x1 + 3 * (1 - s) * s * s * x2 + s ** 3; if (bx < x) lo = s; else hi = s; }
return 3 * (1 - s) ** 2 * s * y1 + 3 * (1 - s) * s * s * y2 + s ** 3;
};
const hexRgb = (h: string) => { const n = parseInt(h.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const mix = (a: string, b: string, k: number) => '#' + hexRgb(a).map((v, i) => Math.round(lerp(v, hexRgb(b)[i], k)).toString(16).padStart(2, '0')).join('');
const morphPath = (n: number, line: (u: number) => number[], target: (u: number) => number[], M: number, lag = 0.35) => {
const pts: string[] = [];
for (let i = 0; i <= n; i++) {
const u = i / n, e = Math.abs(2 * u - 1);
let m = clamp(M * (1 + lag) - lag * (1 - e)); m = m * m * (3 - 2 * m);
const a = line(u), b = target(u);
pts.push(`${lerp(a[0], b[0], m).toFixed(2)},${lerp(a[1], b[1], m).toFixed(2)}`);
}
return 'M' + pts.join(' L');
};
// ── 유리 뒤 글자(플루티드 글라스 리빌) · 9:16 · 60fps · 약 8.5~9초 ──
// 어두운 전시실 벽에 천장 스팟 하나가 켜지고(위에서, 따뜻한 키 — 첫 프레임부터 30% 켜진 채 0.8초 동안 100%, 0.8초부터 글자가 올라온다 · 피드 첫 화면이 빈 검정이 아니게), 헤드라인이 리브(플루티드) 유리 뒤에서 굴절·번진 세로 줄무늬 형체로 올라와 멈춘다(읽힐 듯 말 듯). 차가운 림(≤15%)은 벽이 아니라 유리 골의 오른쪽 면에만 맺힌다(포인트색이 차가운 계열일 때만).
// 유리의 골이 1.8초 동안 위에서 아래로 펴지며(줄마다 ease-in-out 1.44초, 굴절·번짐·색수차·골 그늘·하이라이트·림 → 0) 글자가 초점이 맞듯 선명해져 멈추고, 둘째 줄과 브랜드 칩이 아래에서 올라오면서 헤드라인을 묶음 자리로 밀어 올린 뒤 맺음 2.1초(앞 0.6초 조명 사건 + 1.5초 완전 정지 — 부제·브랜드를 읽을 시간).
// 레퍼런스 문법: 들어옴 = 지수 감속(30fps 한 프레임마다 남은 거리 ×0.85, τ 0.205초 — Maple I 실측), 오버슈트 0, 정지 1.0~2.0초, 페이드·하드컷 없음(유리가 펴지는 것이 전환). 유리 룩은 Paper Shaders fluted glass(lines·prism, highlights 얇은 줄·edges 그늘·한 방향 blur)·Framer Reeded Glass(curvature·dispersion) 의 파라미터 어휘.
// 기술: 전부 WebGL(three.js) 캔버스 한 장 — 글자도 캔버스 2D 로 2배 해상도 텍스처를 만들어 평면에 올린다. 서브프레임 블러(기본 4장, 글자가 한 프레임에 20px 넘게 움직이면 한 장 간격 4px 이하가 되게 최대 16장)는 half-float 렌더타깃에 1/N 가중으로 누적해 캔버스에 한 번 출력(캡처 모드와 무관).
// 모든 프레임은 시각 t 의 순수 함수(벽시계·rAF·three.Clock 없음). 글자가 멈춘 동안 배경은 조용하다(빛 숨쉬기 ±1.2%·5초뿐). 배경의 '사건'은 두 번 — 켜짐(0~0.8초)과 맺음(키라이트가 글자 묶음으로 12% 모이는 0.6초). 키라이트 최고 밝기는 무대색→키색의 74%(헤드라인 윗줄이 빛 웅덩이의 가장 밝은 곳과 겹쳐도 대비 4:1 안팎이 남게 — 85% 에서 줄임).
const SANS = (w: number, px: number) => `${w} ${px}px "Pretendard Variable"`;
const FONT_CSS = ['https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css'];
type Props = {brand: string; headline: string; line?: string | null; colors: {paper: string; ink: string; accent: string}; flutes?: number; sub?: number; fps?: number};
type Plan = {tIn: number; settleIn: number; tClear: number; clearD: number; tSharp: number; holdSharp: number; tLine: number; settleLine: number; tEnd: number; end: number};
type Check = {frame: number; scene: string; at: string};
const RISE = 648, RISE2 = 520, CLEAR_D = 1.8, CLEAR_LAG = 0.2, END_HOLD = 2.1, END_EVENT = 0.6, LIGHT0 = 0.3, LIGHT_D = 0.8, KEY_K = 0.74, BLOCK_DY = 50, HEAD_W = 820, CHIP_MIN = 5.0, RIM_K = 0.13, KEY = '#A69C98', BOUNCE = '#817065', RIM = '#2A3CFF';
const nch = (s: string) => s.replace(/\s/g, '').length;
const norm = (s: string) => s.replace(/\s*\|\s*/g, ' ').replace(/\s+/g, ' ').trim();
const q4 = (x: number) => Math.round(x * 4) / 4;
const EASE_IO = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
// 유리 골 깊이 — GLASS_FRAG 와 같은 식. clear(0→1, 시간에 선형)가 위→아래로 훑고, 줄(y01: 위 0 · 아래 1)마다 자기 창(1−LAG) 안에서 smoothstep 으로 1→0
const glassC = (clear: number, y01: number) => { const u = clamp((clear - CLEAR_LAG * y01) / (1 - CLEAR_LAG)); return 1 - u * u * (3 - 2 * u); };
// 타이밍표 — 선명 정지는 글자 수에 비례(0.75초 + 글자당 0.03초, 1.0~1.35초, 0.25초 격자), 나머지는 고정. 맺음 = 부제·칩이 안착한 뒤 2.1초(앞 0.6초는 조명 사건, 뒤 1.5초 완전 정지)
// 펴짐(1.8초)의 느린 끝 꼬리가 선명 정지에 더해져 실제 정지는 약 1.5~2.0초다 — 그만큼 설계 정지를 줄여 움직임 프로파일(멈춤 40~65%)을 지킨다(96px 축소 프로파일에서 꼬리는 '멈춤'으로 잡힌다 — 실측)
const makePlan = (p: Props): Plan => {
const tIn = 0.8, settleIn = tIn + 0.9, tClear = settleIn + 1.0, tSharp = tClear + CLEAR_D;
const holdSharp = clamp(0.75 + 0.03 * nch(p.headline), 1.0, 1.35), tLine = q4(tSharp + holdSharp), settleLine = tLine + 0.9;
const tEnd = settleLine, end = q4(tEnd + END_HOLD);
return {tIn, settleIn, tClear, clearD: CLEAR_D, tSharp, holdSharp, tLine, settleLine, tEnd, end};
};
// 시각 t 의 상태 — 빛(0→1, 스팟이 300px 내려와 자리를 찾음 · 맺음에 글자 묶음으로 모임), 유리 펴짐 진행(0→1), 헤드라인·둘째 줄·칩의 세로 오프셋과 투명도
// shift = 헤드라인이 혼자일 때(안전 영역 가운데)와 묶음 자리의 차이(px). 부제·칩이 올라올 때 헤드라인은 정지에서 ease-in-out(0.55초)으로 출발해 묶음 자리까지 밀려 올라간다(부제가 보이기 시작하는 tLine+0.05 에 맞춤)
const stateAt = (plan: Plan, t: number, shift: number) => {
const endK = EASE_IO(ramp(t, plan.tEnd, plan.tEnd + END_EVENT));
const light = (LIGHT0 + (1 - LIGHT0) * EASE_IO(ramp(t, 0.0, LIGHT_D))) * (1 + 0.012 * Math.sin(2 * Math.PI * t / 5)) * (1 + 0.05 * endK);
const spotDy = -inOff(t, 0.0, 300), spotK = 1 - 0.12 * endK;
const clear = ramp(t, plan.tClear, plan.tClear + plan.clearD); // 시간에 선형 — 줄마다의 ease-in-out 은 glassC(셰이더)가 건다(이중 easing 금지: 변화가 0.2초에 몰렸다 — 실측)
const dy1 = inOff(t, plan.tIn, RISE) + shift * (1 - EASE_IO(ramp(t, plan.tLine + 0.05, plan.tLine + 0.6))), op1 = ramp(t, plan.tIn, plan.tIn + 0.3);
const dy2 = inOff(t, plan.tLine, RISE2), op2 = ramp(t, plan.tLine + 0.05, plan.tLine + 0.3);
const dy3 = inOff(t, plan.tLine + 0.1, RISE2), op3 = ramp(t, plan.tLine + 0.15, plan.tLine + 0.4);
const curv = glassC(clear, 1), drift = 0; // curv = 화면에 남은 가장 깊은 골(맨 아래 줄이 가장 늦게 펴진다). 골은 흐르지 않는다(흐르면 유리 뒤 정지가 정지로 재지지 않는다 — 실측)
return {light, spotDy, spotK, clear, curv, drift, dy1, op1, dy2, op2, dy3, op3};
};
type State = ReturnType<typeof stateAt>;
const dyOf = (role: Item['role'], s: State): [number, number] => (role === 'headline' ? [s.dy1, s.op1] : role === 'line' ? [s.dy2, s.op2] : [s.dy3, s.op3]);
const splitLines = (s: string, minSplit = 9): string[] => {
const x = s.trim(); if (!x) return [];
if (x.includes('|')) return x.split('|').map((y) => y.trim()).filter(Boolean);
const w = x.split(/\s+/); if (nch(x) < minSplit || w.length < 2) return [x];
let best = [x], sc = Infinity;
for (let a = 1; a < w.length; a++) { const A = w.slice(0, a).join(' '), B = w.slice(a).join(' '); const v = Math.abs(nch(A) - nch(B)); if (v < sc) { sc = v; best = [A, B]; } }
return best;
};
const lum = (h: string) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; const c = hexRgb(h); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
const contrastL = (la: number, lb: number) => (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
const contrast = (a: string, b: string) => contrastL(lum(a), lum(b));
const palette = (c: Props['colors']) => {
const [dark, light] = lum(c.ink) <= lum(c.paper) ? [c.ink, c.paper] : [c.paper, c.ink];
const bg = dark; let fg = light;
for (let k = 0.1; contrast(bg, fg) < 7 && k <= 1; k += 0.1) fg = mix(light, '#ffffff', k);
let sub = mix(fg, bg, 0.25);
for (let k = 0.23; contrast(bg, sub) < 5 && k >= 0; k -= 0.02) sub = mix(fg, bg, k);
// 칩: 포인트색 알약 + 무대색·글자색 중 더 잘 보이는 쪽. 둘 다 CHIP_MIN(5.0 — 작은 글자 기준 4.5 + 압축·화면 여유) 아래면 알약 바탕을 글자 반대쪽으로 섞는다
// (밝은 글자 → 무대색 쪽으로 어둡게, 어두운 글자 → 글자색 쪽으로 밝게). k=1 이면 바탕 = 무대색/글자색이라 대비 7 이상이 보장돼 항상 끝난다
const chipInk = contrast(c.accent, bg) >= contrast(c.accent, fg) ? bg : fg, chipTo = chipInk === fg ? bg : fg;
let chipFill = c.accent;
for (let k = 0.04; contrast(chipFill, chipInk) < CHIP_MIN && k <= 1.0001; k += 0.04) chipFill = mix(c.accent, chipTo, Math.min(1, k));
// 차가운 림은 포인트색이 차가운 계열(색상 170~290°, 채도 0.25 이상)일 때만 — 따뜻한 포인트색과 코발트가 한 영상에 둘이 되지 않게(액센트 하나)
const [ar, ag, ab] = hexRgb(c.accent).map((v) => v / 255), mx = Math.max(ar, ag, ab), mn = Math.min(ar, ag, ab), dl = mx - mn;
const hue = dl === 0 ? 0 : mx === ar ? (60 * ((ag - ab) / dl) + 360) % 360 : mx === ag ? 60 * ((ab - ar) / dl) + 120 : 60 * ((ar - ag) / dl) + 240;
const sat = dl === 0 ? 0 : dl / (1 - Math.abs(mx + mn - 1));
const rimK = sat >= 0.25 && hue >= 170 && hue <= 290 ? RIM_K : 0;
return {bg, fg, sub, accent: c.accent, chipFill, chipInk, key: KEY, bounce: BOUNCE, rim: RIM, rimK};
};
type Pal = ReturnType<typeof palette>;
const v3 = (h: string) => { const [r, g, b] = hexRgb(h); return new THREE.Vector3(r / 255, g / 255, b / 255); };
// ── 글자 측정·배치(브라우저) — 각 글자 조각은 자기 잉크 상자 크기의 캔버스(2배)에 그린다 ──
let ctx: CanvasRenderingContext2D | null = null;
const measure = (text: string, font: string, track = 0) => {
ctx ??= document.createElement('canvas').getContext('2d')!; ctx.font = font; (ctx as any).letterSpacing = `${track}px`;
const m = ctx.measureText(text);
return {adv: m.width, l: m.actualBoundingBoxLeft, r: m.actualBoundingBoxRight, asc: m.actualBoundingBoxAscent, desc: m.actualBoundingBoxDescent};
};
type Item = {k: string; role: 'headline' | 'line' | 'chip'; text: string; font: string; px: number; track: number; fill: string; box: number[]; chip?: {w: number; h: number; fill: string; ink: string}};
const fit = (ls: string[], font: (px: number) => string, cap: number, trackEm: number, maxW: number) => { if (!ls.length) return cap; const w = Math.max(...ls.map((l) => { const m = measure(l, font(100), trackEm * 100); return m.l + m.r; })); return Math.min(cap, Math.floor(100 * maxW / w)); };
const layout = (p: Props, pal: Pal, F: Frame) => {
const S = F.safe, latin = !/[가-힣]/.test(p.headline), ls = splitLines(p.headline);
const hpx = fit(ls, (z) => SANS(800, z), latin ? 196 : 176, -0.02, Math.min(S.w, HEAD_W)), htr = -0.02 * hpx, pitch = Math.round(hpx * 1.1);
const hm = ls.map((l) => measure(l, SANS(800, hpx), htr));
const line = (p.line ?? '').trim(), lpx = line ? fit([line], (z) => SANS(500, z), 52, 0, S.w * 0.9) : 0, lm = line ? measure(line, SANS(500, lpx), 0) : null;
const cpx = 39, ctr = 0.02 * cpx, cm = measure(p.brand.trim(), SANS(600, cpx), ctr), chipH = 78, chipW = Math.round(cm.l + cm.r + 2 * 39);
const hH = pitch * (ls.length - 1) + hm[0].asc + hm[ls.length - 1].desc, gap1 = Math.round(hpx * 0.42), lH = lm ? lm.asc + lm.desc : 0, gap2 = lm ? 40 : 0;
const cy = S.cy + BLOCK_DY, total = hH + gap1 + lH + gap2 + chipH, top = Math.round(cy - total / 2);
const items: Item[] = [];
ls.forEach((text, i) => { const m = hm[i], base = top + hm[0].asc + pitch * i, w = m.l + m.r; items.push({k: 'h' + i, role: 'headline', text, font: SANS(800, hpx), px: hpx, track: htr, fill: pal.fg, box: [S.cx - w / 2, base - m.asc, S.cx + w / 2, base + m.desc]}); });
let y = top + hH + gap1;
if (lm) { const w = lm.l + lm.r; items.push({k: 'line', role: 'line', text: line, font: SANS(500, lpx), px: lpx, track: 0, fill: pal.sub, box: [S.cx - w / 2, y, S.cx + w / 2, y + lH]}); y += lH + gap2; }
items.push({k: 'chip', role: 'chip', text: p.brand.trim(), font: SANS(600, cpx), px: cpx, track: ctr, fill: pal.chipInk, box: [S.cx - chipW / 2, y, S.cx + chipW / 2, y + chipH], chip: {w: chipW, h: chipH, fill: pal.chipFill, ink: pal.chipInk}});
// 헤드라인이 혼자일 때는 안전 영역 가운데보다 50px 아래(바닥 쪽 빈 곳과 무게를 맞춤)에 — 부제·칩이 올 때 묶음 자리로 밀려 올라간다
return {items, shift: Math.round(cy - (top + hH / 2))};
};
// 조각 → 2배 캔버스 텍스처. 잉크 상자가 캔버스 가운데에 오도록 그려서 메시 중심 = 잉크 상자 중심
const makeTexture = (it: Item) => {
const PAD = 6, box = it.box, w = Math.ceil(box[2] - box[0]) + 2 * PAD, h = Math.ceil(box[3] - box[1]) + 2 * PAD;
const c = document.createElement('canvas'); c.width = w * 2; c.height = h * 2;
const g = c.getContext('2d')!; g.scale(2, 2);
if (it.chip) {
g.fillStyle = it.chip.fill; g.beginPath(); g.roundRect(PAD, PAD, it.chip.w, it.chip.h, it.chip.h / 2); g.fill();
g.font = it.font; (g as any).letterSpacing = `${it.track}px`; g.fillStyle = it.chip.ink; g.textBaseline = 'alphabetic';
const m = measure(it.text, it.font, it.track); g.fillText(it.text, PAD + it.chip.w / 2 - (m.l + m.r) / 2 + m.l, PAD + it.chip.h / 2 + (m.asc - m.desc) / 2);
} else {
g.font = it.font; (g as any).letterSpacing = `${it.track}px`; g.fillStyle = it.fill; g.textBaseline = 'alphabetic';
const m = measure(it.text, it.font, it.track); g.fillText(it.text, PAD + m.l, PAD + m.asc);
}
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.NoColorSpace; tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false; tex.needsUpdate = true;
return {tex, w, h};
};
// ── 셰이더 — 값은 전부 표시용 sRGB(0~1) 그대로 다룬다(렌더러 색 변환 없음). 좌표 P 는 px, 위가 0 ──
const QUAD_VERT = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const POOL_GLSL = `uniform vec2 res; uniform vec2 spotC; uniform vec2 spotR; uniform float light;
float pool(vec2 P) { vec2 d = (P - spotC) / spotR; float g = exp(-dot(d, d) * 2.4); return g * (1.0 - 0.5 * smoothstep(0.0, 1.0, -d.y)); }`;
// 무대: 잉크색 벽 + 천장 스팟(타원, 위쪽이 더 빨리 꺼지는 원뿔) + 벨벳 결(정지 노이즈, 빛 안에서만 · 진폭 ±2.5%, 잔 결 위주 — 큰 결이 강하면 펴진 뒤 빛 웅덩이에 구름 얼룩처럼 남았다) + 바닥(벽 밝기에서 이어져 앞으로 어두워짐·젖은 반사·접촉 그늘) + 비네트
// 화면 가장자리에 붙인 림 그라데이션은 없다(빛이 아니라 띠로 읽혔다 — 심사). 차가운 림은 유리 골에만(GLASS_FRAG)
const BG_FRAG = `precision highp float; varying vec2 vUv; ${POOL_GLSL}
uniform vec3 ink; uniform vec3 key; uniform vec3 bounce; uniform float keyK;
float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash21(i), hash21(i + vec2(1.0, 0.0)), f.x), mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), f.x), f.y); }
void main() {
vec2 P = vec2(vUv.x, 1.0 - vUv.y) * res;
float floorY = res.y * 0.80, pl = pool(P) * light;
float nap = vnoise(P / 90.0) * 0.4 + vnoise(P / 14.0) * 0.6;
vec3 wall = ink + (key - ink) * pl * (keyK + (nap - 0.5) * 0.05);
wall += bounce * 0.05 * light * smoothstep(floorY - 300.0, floorY, P.y) * pool(P);
vec2 dr = (vec2(P.x, 2.0 * floorY - P.y) - spotC) / (spotR * vec2(1.35, 1.1));
float refl = exp(-dot(dr, dr) * 1.6) * light;
vec3 wallJ = ink + (key - ink) * pool(vec2(P.x, floorY)) * light * keyK;
vec3 fl = wallJ * mix(0.92, 0.6, smoothstep(floorY, res.y, P.y)) + bounce * refl * 0.26 * (0.9 + (nap - 0.5) * 0.12);
vec3 col = mix(wall, fl, smoothstep(floorY - 6.0, floorY + 6.0, P.y));
col *= 1.0 - 0.25 * exp(-abs(P.y - floorY) / 30.0);
vec2 q = (vUv - 0.5) * vec2(1.0, 0.72); col *= 1.0 - 0.6 * smoothstep(0.32, 0.85, length(q));
gl_FragColor = vec4(col, 1.0);
}`;
// 리브 유리: 골마다 반원통 표면 → 굴절(가로 UV 이동) + 색수차 + 가로 블러(9탭, 반폭 8px·c) × 약한 세로 번짐(3줄, ±4px·c — 글자 조각의 가로 경계가 칼같이 잘려 픽셀 글리치로 읽히지 않게) + 골 그늘·면 음영 + 세로 하이라이트 줄 + 화면 오른쪽(한 변)에서 오는 차가운 림이 골의 오른쪽 면에 맺힘(면적 ≤5%). 골 깊이 c 가 0 이면 그대로 통과
// 펴짐: clear(시간에 선형)가 위→아래로 훑고(LAG 0.2), 줄마다 자기 창(0.8) 안에서 smoothstep 으로 c 1→0 — 이중 easing 없음(옛 식은 변화가 0.2초에 몰려 '툭' 풀렸다)
// 하이라이트: 골이 세로 원통이라 반사는 세로 줄이 된다 — 반벡터를 가로면(xz)에 투영해 잰다(옛 식은 위에서 오는 빛의 y 성분 때문에 pow(·,64) 가 3e-5 로 사실상 0 이었다)
const GLASS_FRAG = `precision highp float; varying vec2 vUv; ${POOL_GLSL}
uniform sampler2D tScene; uniform float flutes; uniform float clear; uniform float lag; uniform float drift; uniform float amp; uniform vec3 tint; uniform vec3 rim; uniform float rimK; uniform float weight;
void main() {
vec2 uv = vUv;
float u = clamp((clear - lag * (1.0 - uv.y)) / (1.0 - lag), 0.0, 1.0), c = 1.0 - u * u * (3.0 - 2.0 * u);
float cell = fract((uv.x + drift) * flutes), x = cell * 2.0 - 1.0;
float nz = sqrt(max(0.0, 1.0 - x * x));
vec3 n = normalize(vec3(x * c, 0.0, mix(1.0, nz, c)));
vec3 r = refract(vec3(0.0, 0.0, -1.0), n, 1.0 / 1.52);
float off = (r.x / max(0.25, -r.z)) * amp / flutes, ca = 0.05 * c, R = 8.0 * c / res.x, V = 4.0 * c / res.y;
vec3 col = vec3(0.0); float ws = 0.0;
for (int j = -1; j <= 1; j++) for (int i = -4; i <= 4; i++) {
float k = float(i) / 4.0, w = exp(-2.0 * k * k) * (j == 0 ? 0.5 : 0.25), dx = k * R, y = uv.y + float(j) * V;
col.r += w * texture2D(tScene, vec2(uv.x + off * (1.0 + ca) + dx, y)).r;
col.g += w * texture2D(tScene, vec2(uv.x + off + dx, y)).g;
col.b += w * texture2D(tScene, vec2(uv.x + off * (1.0 - ca) + dx, y)).b;
ws += w;
}
col /= ws;
col *= 1.0 - 0.4 * pow(abs(x), 8.0) * c;
col *= mix(1.0, 0.9, c) * (1.0 - 0.10 * n.x * c);
col = mix(col, col * (0.55 + 0.45 * tint), 0.10 * c);
vec2 P = vec2(uv.x, 1.0 - uv.y) * res; float pl = pool(P) * light;
float spec = pow(max(dot(n, normalize(vec3(-0.21, 0.0, 1.0))), 0.0), 90.0) * c;
col += vec3(0.92, 0.88, 0.84) * spec * (0.03 + 0.22 * pl);
col += rim * rimK * smoothstep(0.45, 0.95, x) * c * smoothstep(0.5, 1.0, uv.x) * light;
gl_FragColor = vec4(col * weight, weight);
}`;
// 출력: 누적 평균 + 정지 TPDF 디더(±1/255 삼각 분포 = IGN + 해시 − 1, 어두운 그라데이션의 띠 방지 — 화소 자리만의 함수라 움직이는 그레인이 아니다)
const OUT_FRAG = `precision highp float; varying vec2 vUv; uniform sampler2D tAcc;
float h12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
void main() { vec3 col = texture2D(tAcc, vUv).rgb; vec2 q = floor(gl_FragCoord.xy); float dn = fract(52.9829189 * fract(0.06711056 * q.x + 0.00583715 * q.y)) + h12(q) - 1.0; gl_FragColor = vec4(col + dn / 255.0, 1.0); }`;
type Rig = {renderer: THREE.WebGLRenderer; rtScene: THREE.WebGLRenderTarget; rtAcc: THREE.WebGLRenderTarget; rtRead: THREE.WebGLRenderTarget; sceneA: THREE.Scene; sceneB: THREE.Scene; sceneC: THREE.Scene; cam: THREE.OrthographicCamera; bgMat: THREE.ShaderMaterial; glassMat: THREE.ShaderMaterial; texts: {it: Item; mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial}[]; shift: number; gl: string; W: number; H: number};
const quad = (mat: THREE.Material) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat); m.frustumCulled = false; return m; };
const spotOf = (F: Frame) => ({c: new THREE.Vector2(F.W * 0.5, F.H * 0.22), r: new THREE.Vector2(F.W * 0.46, F.H * 0.42)});
const buildRig = (canvas: HTMLCanvasElement, p: Props, pal: Pal, F: Frame, items: Item[], shift: number): Rig => {
const W = F.W, H = F.H;
const renderer = new THREE.WebGLRenderer({canvas, antialias: false, alpha: false, premultipliedAlpha: false, powerPreference: 'high-performance'});
renderer.setPixelRatio(1); renderer.setSize(W, H, false); renderer.outputColorSpace = THREE.LinearSRGBColorSpace; renderer.toneMapping = THREE.NoToneMapping; renderer.autoClear = false;
const ctxGl = renderer.getContext(), dbg = ctxGl.getExtension('WEBGL_debug_renderer_info');
const gl = String(dbg ? ctxGl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : ctxGl.getParameter(ctxGl.RENDERER));
const rtOpt = {minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false, stencilBuffer: false, generateMipmaps: false, format: THREE.RGBAFormat};
// 무대·글자 판은 half-float — 8비트였을 때 어두운 그라데이션이 유리 패스 전에 이미 1단계 계단으로 잘려 출력 디더가 띠를 못 지웠다(실측: 바닥 가로 평균이 정수 단계에 붙음). 검사 읽기는 8비트 판(rtRead)에서
const rtScene = new THREE.WebGLRenderTarget(W, H, {...rtOpt, type: THREE.HalfFloatType});
const rtRead = new THREE.WebGLRenderTarget(W, H, {...rtOpt, type: THREE.UnsignedByteType});
const rtAcc = new THREE.WebGLRenderTarget(W, H, {...rtOpt, type: THREE.HalfFloatType});
const spot = spotOf(F), common = {res: {value: new THREE.Vector2(W, H)}, spotC: {value: spot.c.clone()}, spotR: {value: spot.r.clone()}, light: {value: 0}};
const bgMat = new THREE.ShaderMaterial({vertexShader: QUAD_VERT, fragmentShader: BG_FRAG, depthTest: false, depthWrite: false, uniforms: {...common, ink: {value: v3(pal.bg)}, key: {value: v3(pal.key)}, bounce: {value: v3(pal.bounce)}, keyK: {value: KEY_K}}});
const glassMat = new THREE.ShaderMaterial({vertexShader: QUAD_VERT, fragmentShader: GLASS_FRAG, depthTest: false, depthWrite: false, transparent: true, blending: THREE.CustomBlending, blendEquation: THREE.AddEquation, blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, blendSrcAlpha: THREE.OneFactor, blendDstAlpha: THREE.OneFactor,
uniforms: {res: {value: new THREE.Vector2(W, H)}, spotC: {value: spot.c.clone()}, spotR: {value: spot.r.clone()}, light: {value: 0}, tScene: {value: rtScene.texture}, flutes: {value: p.flutes ?? 24}, clear: {value: 0}, lag: {value: CLEAR_LAG}, drift: {value: 0}, amp: {value: 1.35}, tint: {value: v3(pal.accent)}, rim: {value: v3(pal.rim)}, rimK: {value: pal.rimK}, weight: {value: 1}}});
const outMat = new THREE.ShaderMaterial({vertexShader: QUAD_VERT, fragmentShader: OUT_FRAG, depthTest: false, depthWrite: false, uniforms: {tAcc: {value: rtAcc.texture}}});
const cam = new THREE.OrthographicCamera(0, W, H, 0, -10, 10); cam.position.z = 5;
const sceneA = new THREE.Scene(), sceneB = new THREE.Scene(), sceneC = new THREE.Scene();
const bg = quad(bgMat); bg.renderOrder = -1; sceneA.add(bg);
const texts = items.map((it, i) => {
const {tex, w, h} = makeTexture(it);
const mat = new THREE.MeshBasicMaterial({map: tex, transparent: true, depthTest: false, depthWrite: false, opacity: 0});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); mesh.renderOrder = i; mesh.frustumCulled = false; sceneA.add(mesh);
return {it, mesh, mat};
});
sceneB.add(quad(glassMat)); sceneC.add(quad(outMat));
renderer.compile(sceneA, cam); renderer.compile(sceneB, cam); renderer.compile(sceneC, cam);
return {renderer, rtScene, rtAcc, rtRead, sceneA, sceneB, sceneC, cam, bgMat, glassMat, texts, shift, gl, W, H};
};
const place = (rig: Rig, F: Frame, plan: Plan, t: number) => {
const s = stateAt(plan, t, rig.shift), spot = spotOf(F);
for (const m of [rig.bgMat, rig.glassMat]) { m.uniforms.light.value = s.light; m.uniforms.spotC.value.set(spot.c.x, spot.c.y + s.spotDy); m.uniforms.spotR.value.set(spot.r.x * s.spotK, spot.r.y * s.spotK); }
rig.glassMat.uniforms.clear.value = s.clear; rig.glassMat.uniforms.drift.value = s.drift;
for (const {it, mesh, mat} of rig.texts) {
const [dy, op] = dyOf(it.role, s);
const cx = (it.box[0] + it.box[2]) / 2, cy = (it.box[1] + it.box[3]) / 2 + dy;
mesh.position.set(cx, rig.H - cy, 0); mat.opacity = op; mesh.visible = op > 0.001;
}
return s;
};
// 서브프레임 수: 기본 base(4). 글자가 한 프레임에 20px 넘게 움직이는 빠른 구간(상승 초반 30~50px/프레임)은 한 장 간격이 4px 이하가 되게 늘린다(최대 16) — 4장 간격 8~13px 의 계단 잔상 방지
const subN = (plan: Plan, t: number, fps: number, shift: number, base: number) => {
const a = stateAt(plan, t - 0.5 / fps, shift), b = stateAt(plan, t + 0.5 / fps, shift);
const v = Math.max(b.op1 > 0.01 ? Math.abs(b.dy1 - a.dy1) : 0, b.op2 > 0.01 ? Math.abs(b.dy2 - a.dy2) : 0, b.op3 > 0.01 ? Math.abs(b.dy3 - a.dy3) : 0);
return v > 20 ? Math.min(16, Math.max(base, Math.ceil(v / 4))) : base;
};
// 한 프레임: 서브시각 N 개를 각각 무대(+글자) → 유리 로 그려 1/N 가중으로 누적 → 캔버스에 출력
const renderFrame = (rig: Rig, F: Frame, plan: Plan, t: number, fps: number, N: number) => {
const r = rig.renderer;
r.setRenderTarget(rig.rtAcc); r.setClearColor(0x000000, 0); r.clear(true, false, false);
rig.glassMat.uniforms.weight.value = 1 / N;
for (let k = 0; k < N; k++) {
place(rig, F, plan, t + (k - (N - 1) / 2) / (fps * N));
r.setRenderTarget(rig.rtScene); r.setClearColor(0x000000, 1); r.clear(true, false, false); r.render(rig.sceneA, rig.cam);
r.setRenderTarget(rig.rtAcc); r.render(rig.sceneB, rig.cam);
}
r.setRenderTarget(null); r.setClearColor(0x000000, 1); r.clear(true, false, false); r.render(rig.sceneC, rig.cam);
};
// ── 검사 — 배경만 따로 그려 픽셀을 읽는다(실측): 글자 상자 뒤 밝기 → 대비, 안전 구역 평균 Y·국소 표준편차(135px 축소), 모서리 Y ──
const readBackground = (rig: Rig, F: Frame, plan: Plan, t: number) => {
const r = rig.renderer, W = rig.W, H = rig.H;
place(rig, F, plan, t); rig.texts.forEach((x) => { x.mesh.visible = false; });
r.setRenderTarget(rig.rtRead); r.setClearColor(0x000000, 1); r.clear(true, false, false); r.render(rig.sceneA, rig.cam);
const px = new Uint8Array(W * H * 4); r.readRenderTargetPixels(rig.rtRead, 0, 0, W, H, px); r.setRenderTarget(null);
rig.texts.forEach((x) => { x.mesh.visible = true; });
const at = (x: number, y: number) => { const i = ((H - 1 - y) * W + x) * 4; return [px[i], px[i + 1], px[i + 2]]; };
return {px, at, Y: (x: number, y: number) => { const [a, b, c] = at(x, y); return 0.299 * a + 0.587 * b + 0.114 * c; }, L: (x: number, y: number) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; const [a, b, c] = at(x, y); return 0.2126 * f(a) + 0.7152 * f(b) + 0.0722 * f(c); }};
};
// 칩 대비 실측 — 글자를 보이게 둔 무대 판을 읽어 알약 안쪽(둥근 끝 제외)의 바탕(중앙값)과 획 안쪽(글자 쪽 끝 2%)의 상대휘도로 잰다
const chipContrastAt = (rig: Rig, F: Frame, plan: Plan, t: number, box: number[], ink: string) => {
const r = rig.renderer, W = rig.W, H = rig.H;
place(rig, F, plan, t);
r.setRenderTarget(rig.rtRead); r.setClearColor(0x000000, 1); r.clear(true, false, false); r.render(rig.sceneA, rig.cam);
const [l, tp, rr, b] = box, h = b - tp, x0 = Math.max(0, Math.round(l + h / 2)), x1 = Math.min(W, Math.round(rr - h / 2)), y0 = Math.max(0, Math.round(tp + 6)), y1 = Math.min(H, Math.round(b - 6));
if (x1 - x0 < 4 || y1 - y0 < 4) { r.setRenderTarget(null); return null; }
const px = new Uint8Array((x1 - x0) * (y1 - y0) * 4); r.readRenderTargetPixels(rig.rtRead, x0, H - y1, x1 - x0, y1 - y0, px); r.setRenderTarget(null);
const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
const Ls: number[] = []; for (let i = 0; i < px.length; i += 4) Ls.push(0.2126 * f(px[i]) + 0.7152 * f(px[i + 1]) + 0.0722 * f(px[i + 2]));
Ls.sort((a, c) => a - c);
const fillL = Ls[Math.floor(Ls.length / 2)], inkL = lum(ink) > fillL ? Ls[Math.floor(Ls.length * 0.98)] : Ls[Math.floor(Ls.length * 0.02)];
return +contrastL(fillL, inkL).toFixed(2);
};
const runCheck = (rig: Rig, p: Props, F: Frame, plan: Plan, pal: Pal, items: Item[], t: number, c: Check, fps: number) => {
const over: string[] = [], hint: string[] = [], s = stateAt(plan, t, rig.shift), S = F.safe, bg = readBackground(rig, F, plan, t);
const vis = items.map((it) => { const [dy, op] = dyOf(it.role, s); return {...it, box: [it.box[0], it.box[1] + dy, it.box[2], it.box[3] + dy].map(Math.round), op: +op.toFixed(3)}; }).filter((x) => x.op > 0.05);
// 135px 축소(8×8 평균) 밝기 격자
const CW = Math.floor(rig.W / 8), CH = Math.floor(rig.H / 8), cell = new Float32Array(CW * CH);
for (let cy = 0; cy < CH; cy++) for (let cx = 0; cx < CW; cx++) { let a = 0; for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) a += bg.Y(cx * 8 + x, cy * 8 + y); cell[cy * CW + cx] = a / 64; }
const zx0 = Math.ceil(S.x / 8), zx1 = Math.floor((S.x + S.w) / 8), zy0 = Math.ceil(S.y / 8), zy1 = Math.floor((S.y + S.h) / 8);
let zs = 0, zn = 0, stdMax = 0;
for (let cy = zy0; cy < zy1; cy++) for (let cx = zx0; cx < zx1; cx++) {
zs += cell[cy * CW + cx]; zn++;
if (cy > zy0 && cy < zy1 - 1 && cx > zx0 && cx < zx1 - 1) { let m = 0, q = 0; for (let j = -1; j <= 1; j++) for (let i = -1; i <= 1; i++) { const v = cell[(cy + j) * CW + cx + i]; m += v; q += v * v; } m /= 9; stdMax = Math.max(stdMax, Math.sqrt(Math.max(0, q / 9 - m * m))); }
}
const zoneY = zs / zn, corner = (x0: number, y0: number) => { let a = 0; for (let y = 0; y < 15; y++) for (let x = 0; x < 15; x++) a += cell[(y0 + y) * CW + x0 + x]; return a / 225; };
const corners = [corner(0, 0), corner(CW - 15, 0), corner(0, CH - 15), corner(CW - 15, CH - 15)].map((v) => +v.toFixed(1));
const lit = t >= plan.tIn;
if (lit && (zoneY < 30 || zoneY > 95)) { over.push('zone-light'); hint.push(`글자 구역 평균 Y ${zoneY.toFixed(1)} — 30~95 밖(스팟 세기·색 문제)`); }
if (lit && stdMax > 12) { over.push('zone-busy'); hint.push(`글자 구역 국소 표준편차 ${stdMax.toFixed(1)} > 12(배경이 바쁨)`); }
// 모서리 상한 = 24, 무대색(어두운 색) 자체가 그보다 밝으면 무대색 밝기 + 4 — 모서리는 무대색보다 어두워질 수 없어서(#1A1F1C 처럼 밝은 먹색은 빛이 새지 않아도 Y 26 안팎)
const [ir, ig, ib] = hexRgb(pal.bg), cornerMax = Math.max(24, 0.299 * ir + 0.587 * ig + 0.114 * ib + 4);
if (lit && Math.max(...corners) > cornerMax) { over.push('corner-light'); hint.push(`모서리 Y ${corners.join('/')} — ${cornerMax.toFixed(1)} 초과(낙차 부족)`); }
for (const x of vis) {
const [l, tp, r, b] = x.box;
if (tp < S.y - 1 || b > S.y + S.h + 1 || l < S.x - 2 || r > S.x + S.w + 2) { over.push('safe-area'); hint.push(`"${x.text}" 잉크 ${x.box.join(',')} 가 안전 영역 밖`); }
if (x.role === 'headline' && x.px < 110) { over.push('big-small'); hint.push(`"${x.text}" ${x.px}px < 110px — 글자가 길다. 줄인 안을 사용자에게`); }
if (x.chip) {
if (x.op > 0.95) { const cc = chipContrastAt(rig, F, plan, t, x.box, x.chip.ink); (x as any).contrast = cc; if (cc != null && cc + 1e-6 < 4.5) { over.push('chip-contrast'); hint.push(`브랜드 칩 글자 대비 ${cc} < 4.5(실측) — 코드 문제`); } }
continue;
}
let a = 0, n = 0; for (let y = Math.max(0, tp); y < Math.min(rig.H, b); y += 2) for (let xx = Math.max(0, l); xx < Math.min(rig.W, r); xx += 2) { a += bg.L(xx, y); n++; }
const cr = n ? contrastL(lum(x.fill), a / n) : 0;
(x as any).bgL = +(a / Math.max(1, n)).toFixed(4); (x as any).contrast = +cr.toFixed(2);
if (s.curv < 0.5 && cr + 1e-6 < 4.5) { over.push('contrast'); hint.push(`"${x.text}" 대비 ${cr.toFixed(2)} < 4.5 — 글자색·무대색을 사용자에게`); }
}
const hit = (a: number[], b: number[]) => Math.min(a[2], b[2]) - Math.max(a[0], b[0]) > 2 && Math.min(a[3], b[3]) - Math.max(a[1], b[1]) > 2;
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) if (hit(vis[i].box, vis[j].box)) { over.push('text-collide'); hint.push(`"${vis[i].text}" 과 "${vis[j].text}" 잉크가 겹침`); }
if (c.scene === 'glass' && s.curv < 0.9) { over.push('glass-off'); hint.push(`유리 뒤 구간인데 골 깊이 ${s.curv.toFixed(2)} < 0.9`); }
if (c.scene !== 'glass' && s.curv > 0.001) { over.push('glass-on'); hint.push(`선명 구간인데 골 깊이 ${s.curv.toFixed(3)} > 0`); }
if (c.at === 'mid' && c.scene !== 'glass') {
const got = vis.filter((x) => x.role === 'headline' && x.op > 0.95).map((x) => x.text).join(' ');
if (norm(got) !== norm(p.headline)) { over.push('text-mismatch'); hint.push(`헤드라인: 화면 "${got}" ≠ 입력 "${p.headline}"`); }
}
if (c.scene === 'line' || c.scene === 'end') {
if ((p.line ?? '').trim() && !vis.some((x) => x.role === 'line' && x.op > 0.95 && norm(x.text) === norm(p.line!))) { over.push('text-mismatch'); hint.push('둘째 줄이 안 보이거나 입력과 다름'); }
if (!vis.some((x) => x.role === 'chip' && x.op > 0.95 && x.text === p.brand.trim())) { over.push('text-mismatch'); hint.push('브랜드 칩이 안 보이거나 입력과 다름'); }
}
console.log('DUCHAMP_CHECK ' + JSON.stringify({...c, t: +t.toFixed(3), over: [...new Set(over)], hint, light: +s.light.toFixed(3), curv: +s.curv.toFixed(3), zone: {meanY: +zoneY.toFixed(1), localStdMax: +stdMax.toFixed(1), corners}, gl: rig.gl, items: vis.map(({text, px, box, op, fill, contrast, bgL}: any) => ({text, px, box, op, fill, contrast, bgL}))}));
};
let fontsDone: Promise<void> | null = null;
const loadFonts = (specs: [string, string][]) => (fontsDone ??= (async () => {
for (const href of FONT_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) { await document.fonts.load(s, t); if (!document.fonts.check(s, t)) throw new Error('폰트 없음: ' + s); }
await document.fonts.ready;
})());
const Main: React.FC<Props & {frameInfo: Frame; plan: Plan; checks: Check[]}> = (p) => {
const frame = useCurrentFrame(), {fps} = useVideoConfig(), F = p.frameInfo, plan = p.plan;
const pal = useMemo(() => palette(p.colors), [p]);
const canvasRef = useRef<HTMLCanvasElement>(null), rigRef = useRef<{rig: Rig; items: Item[]} | null>(null);
const [ok, setOk] = useState(false), [h] = useState(() => delayRender('fonts+three'));
useEffect(() => {
const all = p.headline + (p.line ?? '') + p.brand;
loadFonts([[SANS(800, 100), all], [SANS(500, 40), all], [SANS(600, 30), all]]).then(() => {
const {items, shift} = layout(p, pal, F);
rigRef.current = {rig: buildRig(canvasRef.current!, p, pal, F, items, shift), items};
flushSync(() => setOk(true));
continueRender(h);
}).catch((e) => cancelRender(e));
}, []);
// hf-seek → flushSync(setFrame) 안에서 동기로 그린다 → waitUntil 이 풀리기 전에 GL 명령이 끝난다
useLayoutEffect(() => {
const cur = rigRef.current; if (!ok || !cur) return;
const t = frame / fps, N = subN(plan, t, fps, cur.rig.shift, Math.max(1, Math.round(p.sub ?? 4)));
renderFrame(cur.rig, F, plan, t, fps, N);
const chk = p.checks.find((c) => c.frame === frame);
if (chk) runCheck(cur.rig, p, F, plan, pal, cur.items, t, chk, fps);
}, [ok, frame]);
return <AbsoluteFill style={{background: pal.bg}}><canvas ref={canvasRef} width={F.W} height={F.H} style={{position: 'absolute', left: 0, top: 0, width: F.W, height: F.H, display: 'block'}} /></AbsoluteFill>;
};
const inputErrors = (p: Props) => {
const e: {code: string; hint: string}[] = [], hex = /^#[0-9a-fA-F]{6}$/;
if (!p.brand?.trim()) e.push({code: 'input', hint: 'brand(브랜드명)가 비었다'});
if (p.brand && nch(p.brand) > 24) e.push({code: 'too-long', hint: 'brand 는 24자 이하'});
if (!p.headline?.trim()) e.push({code: 'input', hint: 'headline(헤드라인)이 비었다'});
if (p.headline && splitLines(p.headline).length > 2) e.push({code: 'too-long', hint: '헤드라인은 2줄까지("|" 로 나눔)'});
if (p.line && nch(p.line) > 40) e.push({code: 'too-long', hint: '둘째 줄은 40자 이하'});
for (const k of ['paper', 'ink', 'accent'] as const) if (!hex.test(p.colors?.[k] ?? '')) e.push({code: 'input', hint: `colors.${k} 는 #RRGGBB`});
if (p.flutes != null && (p.flutes < 12 || p.flutes > 48)) e.push({code: 'input', hint: 'flutes(골 수)는 12~48'});
if (!e.length && contrast(p.colors.paper, p.colors.ink) < 7) e.push({code: 'contrast', hint: `paper·ink 대비 ${contrast(p.colors.paper, p.colors.ink).toFixed(2)} < 7 — 밝은 글자색·어두운 무대색을 사용자에게`});
return e;
};
const beatsOf = (plan: Plan) => [
{label: '조명', t0: 0, t1: plan.tIn, knows: '어두운 벽에 천장 스팟이 켜진다(무대)', enter: `첫 프레임부터 스팟이 ${LIGHT0 * 100}% 켜져 있고 ${LIGHT_D}초 동안 ease-in-out 으로 100%, 빛 웅덩이가 300px 내려와 자리를 잡음(×0.85/f)`, hold: '—', exit: '남음', holdCheck: false},
{label: '유리 뒤', t0: plan.tIn, t1: plan.tClear, knows: '무언가 쓰여 있다 — 글자 같은데 읽히지 않는다', enter: `${RISE}px 아래에서 지수 감속 상승(×0.85/f, τ 0.205초), 투명도 0.3초, 리브 유리 뒤 줄무늬 형체`, hold: `약 ${(plan.tClear - plan.settleIn).toFixed(1)}초(안착 뒤)`, exit: '유리가 펴짐'},
{label: '선명', t0: plan.tClear, t1: plan.tLine, knows: '헤드라인', enter: `골이 ${plan.clearD}초 동안 위→아래로 펴짐(줄마다 ease-in-out ${(plan.clearD * (1 - CLEAR_LAG)).toFixed(2)}초, 굴절·번짐·색수차·하이라이트·림 → 0)`, hold: `${plan.holdSharp.toFixed(2)}초`, exit: '묶음 자리로 밀려 올라감'},
{label: '부제·칩', t0: plan.tLine, t1: plan.settleLine, knows: '부제 + 누구(브랜드 칩)', enter: `${RISE2}px 아래에서 같은 곡선으로 상승, 칩은 0.1초 늦게 · 헤드라인은 정지에서 ease-in-out 0.55초로 묶음 자리까지 밀려 올라감`, hold: '—', exit: '남음', holdCheck: false},
{label: '맺음', t0: plan.tEnd, t1: plan.end, knows: '헤드라인 + 부제 + 브랜드', enter: `안착과 함께 키라이트가 ${END_EVENT}초 동안 글자 묶음으로 12% 모임(끝 신호)`, hold: `${END_HOLD.toFixed(1)}초(뒤 ${(END_HOLD - END_EVENT).toFixed(1)}초 완전 정지)`, exit: '끝(정지)'},
];
const checksOf = (plan: Plan, fps: number): Check[] => {
const f = (t: number) => Math.round(t * fps);
return [{frame: f((plan.settleIn + plan.tClear) / 2), scene: 'glass', at: 'mid'}, {frame: f(plan.tSharp + 0.05), scene: 'sharp', at: 'start'}, {frame: f((plan.tSharp + plan.tLine) / 2), scene: 'sharp', at: 'mid'}, {frame: f(plan.tLine - 0.05), scene: 'sharp', at: 'end'}, {frame: f(plan.settleLine + 0.4), scene: 'line', at: 'mid'}, {frame: f(plan.end - 0.6), scene: 'end', at: 'mid'}];
};
const stillsOf = (plan: Plan, fps: number) => { const f = (t: number) => Math.round(t * fps); return [{frame: f(plan.tClear - 0.4), name: '1-glass'}, {frame: f(plan.tClear + 0.5 * plan.clearD), name: '2-clearing'}, {frame: f(plan.tSharp + 0.8), name: '3-sharp'}, {frame: f(plan.end - 0.5), name: '4-end'}]; };
const DEF: Props = {brand: '무늬 공방', headline: '유리 너머의 | 계절', line: '가을 전시 10.24 — 11.09', colors: {paper: '#EDE8DE', ink: '#120F0E', accent: '#2B30AE'}};
const Root: React.FC = () => (
<Composition id="Main" component={Main as any} width={1080} height={1920} fps={60} durationInFrames={540} defaultProps={DEF as any}
calculateMetadata={({props}: any) => {
const F = frameOf('9:16'), fps = props.fps ?? 60, input = inputErrors(props), plan = makePlan(props);
return {width: F.W, height: F.H, fps, durationInFrames: Math.round(plan.end * fps), props: {...props, format: '9:16', frameInfo: F, plan, input, files: [], beats: beatsOf(plan), checks: checksOf(plan, fps), stills: stillsOf(plan, fps), profile: {still: [40, 65], slowMax: 40, hold: [0.8, 2.5]}, palette: (() => { const q = palette(props.colors); return {bg: q.bg, fg: q.fg, accent: q.accent, chip: q.chipFill, chipInk: q.chipInk, chipContrast: +contrast(q.chipFill, q.chipInk).toFixed(2)}; })()}};
}} />
);
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));
};
```
## 6. 실행
```bash
npm install --no-audit --no-fund
node render.mjs stills # 검사 + out/beats.md(비트맵) + out/still-*.png(정지 4장) + out/contact.png(0.5초마다 1장) (GPU 가 있는 맥에서 약 15초 — 2026-10-11 실측 13초)
node render.mjs mp4 # 위 전부 + out/video.mp4(60fps · 서브프레임 4장, 빠른 구간 최대 16장 · 무음 · 키프레임 1개) + 움직임 프로파일 검사 (GPU 가 있는 맥에서 약 30초 — 2026-10-11 실측 30~31초)
```
- 게이트(§2e): `출력: 계획 먼저` 일 때만 stills 결과를 보여 승인받은 뒤 mp4 를 실행한다. 기본(`MP4`)은 묻지 않고 바로 mp4 까지 간다.
- **종료 코드 2 = 검사 실패 → 거기서 멈춘다.** `out/check.json` 의 `overflow`·`hint` 를 읽고 원인(글자 길이·색)을 고친 뒤 다시 실행한다. 글자를 줄이거나 바꿔야 하면 마음대로 고치지 말고 사용자에게 고친 안을 물어본다.
- 폰트를 못 불러오면 오류로 멈춘다(대체 글꼴로 조용히 그리지 않는다).
- MP4 는 렌더를 무손실 중간 파일(`out/_lossless.mp4`, 끝나면 지움)로 받은 뒤 x264 로 한 번 인코딩한다 — 영상 전체가 키프레임 하나로 시작하는 한 GOP(정지 화면 중간에 화질이 한 프레임 바뀌는 깜빡임이 없다)·crf 13·BT.709·약 5~7Mbps.
- GPU 가 잡히는지 확인하려면 `out/check.json` 의 `scenes[0].gl`(예: `ANGLE (Apple, ANGLE Metal Renderer …)`)을 본다. `SwiftShader` 가 적혀 있으면 소프트웨어 렌더라 느린 것이 정상이다. 하드웨어 GPU 를 억지로 켜거나 끄려면 `PRODUCER_BROWSER_GPU_MODE=hardware`(또는 `software`) 를 앞에 붙인다.
## 7. 검증 (반드시, 끝났다고 말하기 전에)
1. `out/check.json`: `ok: true`, `overflow: []`, `mp4.keyframes: 1`. `overflow` 뜻: `input`·`too-long`·`contrast`(색 조합·글자 길이 → 사용자에게)·`big-small`·`safe-area`·`text-mismatch`·`text-collide`·`glass-off`·`glass-on`·`zone-light`·`zone-busy`·`corner-light`·`chip-contrast`·`profile`·`hold`·`pop`·`format`·`audio`(뒤 열한 개는 코드 문제라 그대로 보고한다).
2. `out/still-1-glass.png` ~ `out/still-4-end.png` 를 **한 장씩 직접 열어 보고**(이미지 보기 도구) §2(d) 체크리스트 1·2·3·5 를 확인한다.
3. `out/contact.png` 를 열어 §2(d) 4 를 확인한다.
4. 문제가 있으면 원인(글자·props)을 고쳐 다시 렌더한다.
## 8. 보고
결과 파일 경로(`out/video.mp4`, `out/still-*.png`, `out/contact.png`, `out/beats.md`), 게이트를 거쳤는지 건너뛰었는지, check.json 의 프로파일(멈춤/느린/큰 %)·비트별 정지 초·튐·`gl`(GPU 경로), 직접 본 확인 결과, 걸린 시간, 고친 것이 있으면 무엇을 왜 고쳤는지. 그리고 한 줄: **"이 영상은 무음입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 저작권 걱정 없는 음원으로 붙이세요."**
폰트(SIL OFL 1.1, 상업 사용 가능): 헤드라인·부제·칩 모두 Pretendard. 렌더: three.js 0.186.1(MIT) · HyperFrames(Apache 2.0).
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요.
- 붙여 넣기
Codex(권장)나 Claude Code 에 붙여 넣고 브랜드·색·컷별 글자를 알려 주세요. 이미지 생성 없이 코드만으로 렌더해서 어느 쪽이든 그대로 됩니다.
- 올리기
MP4 파일을 받아 릴스·쇼츠로 올리세요. 음악은 편집 앱에서 붙이세요.
컷 구성
- 컷 1 · 0.8초 · 조명
- 첫 화면부터 은은하게 켜져 있던 천장 스팟이 밝아지고, 빛 웅덩이가 내려와 자리를 잡는다
- 컷 2 · 1.9초 · 유리 뒤
- 헤드라인이 리브 유리 뒤 세로 줄무늬 형체로 아래에서 빠르게 올라와 멈춘다 — 글자 같은데 읽히지 않는다
- 컷 3 · 2.8초 · 선명
- 유리 골이 1.8초 동안 위에서 아래로 펴지며 굴절·번짐이 사라지고, 헤드라인이 또렷하게 멈춘다
- 컷 4 · 0.9초 · 부제·칩
- 부제와 브랜드 칩이 같은 곡선으로 올라오며 헤드라인을 묶음 자리로 밀어 올린다
- 컷 5 · 2.1초 · 맺음
- 키라이트가 글자 묶음으로 살짝 모이고, 헤드라인·부제·브랜드가 1.5초 동안 완전히 멈춘다
예시 영상의 컷입니다. 레시피는 한 장면 · 비트 5개 · 약 8~9초 안에서 내가 준 항목 수에 맞춰 컷을 나눕니다.
지킬 것
- 잘 맞는 때: 전시·신제품·시즌 컬렉션처럼 공개를 앞두고 헤드라인 한 줄로 궁금하게 만들 때
- 헤드라인·부제·브랜드명은 내가 알려 준 글자만 씁니다 — 날짜·장소 같은 사실을 지어 넣지 않게 돼 있어요.
- 헤드라인은 1~2줄, 한 줄에 8자 안팎이 넉넉합니다. 길면 코드 검사가 멈추고 줄인 안을 물어봐요.
- WebGL(three.js)로 그립니다. GPU 가 있는 맥·PC 에서 약 30초, GPU 가 없는 서버에서는 훨씬 오래 걸려요.
- 60fps 무음 MP4 입니다. 음악은 인스타그램·캡컷 같은 편집 앱에서 붙이세요.


