# AI 꾸러미 — 경로 추적 영상 굽기 (프레임마다 수렴 → mp4) — Offline frame-by-frame rendering (deterministic time)
> 시각 t 를 프레임마다 정확히 놓고 샘플을 다 모아 찍은 뒤 다음 프레임 — 헤드리스 크롬 + ffmpeg 로 블렌더 없이 사진 같은 설명 영상을 mp4 로.  
> 견본: https://ai-techstudio.web.app/#t/i547

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 경로 추적 영상 굽기 (프레임마다 수렴 → mp4) — Offline frame-by-frame rendering (deterministic time)

#### 1. 목표
기계 원리 설명 영상 (부품이 움직이는 단면)을(를) 경로 추적으로 한 프레임씩 구워 mp4 로 만들어 줘 — 페이지는 window.__renderAt(t) 로 그 시각 장면을 샘플을 다 모아 그리고, puppeteer 가 찍어 ffmpeg 로 묶게. 분위기는 어두운 스튜디오 · 자막.

#### 2. 핵심 기술 용어
- **Offline frame-by-frame rendering (deterministic time)** — 실제 시계 대신 프레임 번호로 시간을 정해 한 장씩 굽기
- **Headless Chrome (puppeteer) screenshot → ffmpeg image2pipe** — 보이지 않는 크롬이 찍은 PNG 를 ffmpeg 에 바로 흘려 mp4
- **Motion blur via shutter-time sampling** — 빛줄마다 셔터가 열린 동안의 다른 순간 — 평균이 곧 모션 블러
- **Convergence per frame (spp budget)** — 프레임마다 정한 샘플 수까지 다 모은 뒤 찍기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- 예외: 페이지는 three-gpu-pathtracer 로 그리고, 굽기는 Node 스크립트(puppeteer-core + ffmpeg)로 한다. 폰 · 60fps 조건은 해당 없음 — 화면 밖에서 한 장씩 굽는다.

#### 4. 조건
- 페이지의 시간은 오직 __renderAt(t) 의 t 로 — performance.now · requestAnimationFrame 시간 · Math.random 으로 움직이지 않게 (같은 t 는 언제나 같은 그림)
- __renderAt 은 샘플을 다 쌓고, 노이즈 제거 · 자막까지 그린 뒤 requestAnimationFrame 두 번을 기다려 화면이 실제로 바뀐 다음 끝낸다
- 헤드리스 크롬은 GPU 를 꼭 켠다: --use-angle=d3d11(윈도) --enable-gpu --ignore-gpu-blocklist --enable-unsafe-webgpu, protocolTimeout: 0 — 안 그러면 소프트웨어 렌더로 수십 배 느리다
- ffmpeg: -f image2pipe -framerate 30 -c:v png -i - -c:v libx264 -pix_fmt yuv420p -crf 16 -movflags +faststart (유튜브 · 폰에서 재생되게)
- 진행률(몇 번째 프레임 · 프레임당 초 · 남은 시간)을 찍고, 미리 몇 장만 정지 그림으로 뽑아 확인하는 모드를 둔다

#### 5. 완성 기준 (이게 보이면 성공)
- 느린 컴퓨터에서 구워도 mp4 가 정확히 30fps 로 끊김 없이 재생되고, 길이가 (끝초 − 시작초)와 같다
- 같은 t 를 두 번 구우면 같은 그림이 나온다 (시간이 실제 시계에 묶이지 않음)
- 모션 블러를 켜면 빠르게 움직이는 부품이 진행 방향으로 번지고, 끄면 프레임마다 또렷한 한 순간
- 프레임마다 노이즈 제거를 하면 32 샘플로도 자글거림이 없다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 실시간 녹화는 컴퓨터가 느리면 프레임이 빠지고 노이즈도 덜 걷힌다. 굽기는 시간을 프레임 번호로 정한다: t = i / 30. 한 장에 몇 초가 걸려도 영상은 정확히 30fps.
- 페이지가 window.__renderAt(t) 를 내놓는다: 그 시각으로 카메라 · 부품을 놓고, 정한 샘플까지 쌓고, (노이즈 제거 후) 다 그리면 끝을 알린다. 바깥 스크립트는 찍기만 한다.
- puppeteer 가 1/30초씩 __renderAt 을 부르고 page.screenshot 으로 PNG — ffmpeg 의 표준 입력(image2pipe)에 바로 흘려 libx264 mp4. 중간 파일이 없다.
- 모션 블러가 공짜: 빛줄마다 셔터가 열린 동안(180° = 프레임 시간의 절반)의 아무 순간에 부품을 놓으면, 평균이 곧 움직임 번짐.
- 노이즈 제거를 프레임마다 하면 샘플을 몇 분의 일로 — 대신 프레임끼리 노이즈 무늬가 달라 살짝 일렁일 수 있다(시드 고정 · 샘플을 조금 더).

## 핵심 코드 — puppeteer 로 __renderAt(t) 를 1/30초씩 → ffmpeg 로 mp4
(발췌: 새로 씀 — 「도구와 기계의 원리」 tools/render_pt.mjs 를 정리)
```js
import puppeteer from 'puppeteer-core';
import { spawn } from 'node:child_process';

const FPS = 30;
const browser = await puppeteer.launch({
  executablePath: CHROME, headless: 'new', protocolTimeout: 0,
  args: ['--use-angle=d3d11', '--enable-gpu', '--ignore-gpu-blocklist', '--enable-unsafe-webgpu'],
  defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
});
const page = await browser.newPage();
await page.goto(URL + '?spp=32', { waitUntil: 'networkidle0', timeout: 180000 });
await page.waitForFunction('window.__ready === true', { timeout: 180000 });

const ff = spawn(FFMPEG, ['-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
  '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-preset', 'slow', '-movflags', '+faststart', OUT]);
for (let i = Math.round(T0 * FPS); i < Math.round(T1 * FPS); i++) {
  await page.evaluate((t) => window.__renderAt(t), i / FPS);   // 그 시각 장면 · 샘플 다 쌓기
  const png = await page.screenshot({ type: 'png' });
  if (!ff.stdin.write(png)) await new Promise((r) => ff.stdin.once('drain', r));
}
ff.stdin.end();
await new Promise((r) => ff.on('close', r));
await browser.close();
```

## 흔한 실수 · 확인 목록
- [ ] **헤드리스 크롬이 소프트웨어로 그려 한 장에 몇 분** — 기본 헤드리스는 GPU 를 안 쓸 수 있다. GPU 옵션을 주고 WEBGL_debug_renderer_info 로 실제 그래픽 카드 이름을 찍어 확인한다 (「도구와 기계의 원리」 렌더 스크립트도 시작할 때 GPU 이름을 찍는다).
- [ ] **그리기 직후 찍으면 지난 프레임이 찍힌다** — 화면 합성이 한 박자 늦다. __renderAt 끝에 requestAnimationFrame 두 번을 기다린다.
- [ ] **탭이 뒤에 있으면 requestAnimationFrame 이 멈춘다** — 보통 크롬에서 뒤로 간 탭은 rAF 가 거의 멈춰 굽기가 수십 배 느려진다(이 견본을 자동 시험할 때 겪음). 헤드리스로 굽거나 굽는 동안 탭을 앞에 둔다.
- [ ] **프레임마다 노이즈 무늬가 달라 일렁인다** — 노이즈 제거 후에도 남는 저주파 얼룩이 프레임마다 다르다. 샘플을 조금 더 쌓거나, 프레임 사이 시드를 고정 · 시간 방향으로 살짝 섞는다.

## 완성 기준 체크리스트
- [ ] 느린 컴퓨터에서 구워도 mp4 가 정확히 30fps 로 끊김 없이 재생되고, 길이가 (끝초 − 시작초)와 같다
- [ ] 같은 t 를 두 번 구우면 같은 그림이 나온다 (시간이 실제 시계에 묶이지 않음)
- [ ] 모션 블러를 켜면 빠르게 움직이는 부품이 진행 방향으로 번지고, 끄면 프레임마다 또렷한 한 순간
- [ ] 프레임마다 노이즈 제거를 하면 32 샘플로도 자글거림이 없다

## 이 기술 정보
- id: `i547` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 보통 · 폰 부담 무거움 — 프레임당 렌더 시간 × 프레임 수. 1080p · 32 샘플 + OIDN 이면 프레임당 몇 초 — 10초(300프레임) 영상이 10 ~ 20분. 화면 밖(헤드리스)에서 돌아 폰과는 무관.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i547
- 쓰면 좋을 때: 유튜브 설명 영상 · 기계 원리 애니메이션을 블렌더 없이 코드로 / 같은 장면 코드로 실시간 미리 보기(콘티)와 고화질 굽기를 둘 다 / 느린 노트북에서도 완벽한 30 · 60fps 영상이 필요할 때
- 쓰지 말 때: 사람이 직접 조작하는 화면 녹화 — 그냥 화면 녹화(OBS)가 낫다 / 몇 분짜리 긴 영상을 높은 샘플로 — 프레임당 수십 초면 몇 시간. 장면을 나눠 굽거나 샘플 · 해상도를 낮추고 노이즈 제거

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeBake — `src/demos/demosPathTrace.ts:167`
```ts
function makeBake(): Scene3D {
  const { scene, camera } = blank();
  const pt = new PathTracer();
  const dn = new Denoiser();
  const show = new Show();
  const overlay = new Overlay();
  const NF = 24;
  let clean = true;
  const FPS = 12;
  const frames: THREE.WebGLRenderTarget[] = [];
  let fw = 0;
  let fh = 0;
  let f = 0;
  let baked = 0;
  let spp = 24;
  let blur = true;
  let playT = 0;
  let doneT = -1;
  let tags: Tag[] = [];
  const clear = new THREE.Color();
  aim(pt.camera, new THREE.Vector3(0.6, 1.45, 5.0), new THREE.Vector3(-0.4, 0.55, 0.2));
  const restart = (): void => {
    f = 0;
    baked = 0;
    doneT = -1;
    pt.reset();
  };
  return {
    scene,
    camera,
    tone: THREE.NoToneMapping,
    update(_t, dt) {
      playT += dt;
      if (doneT >= 0) {
        doneT += dt;
        if (doneT > 5) restart();
      }
    },
    render(r, w, h) {
      const big = w >= 700;
      const pad = Math.max(4, Math.round(Math.min(w, h) * 0.025));
      const stripH = Math.round(h * 0.2);
      let mh = h - stripH - pad * 3;
      let mw = Math.round(mh * (16 / 9));
      if (mw > w * 0.62) {
        mw = Math.round(w * 0.62);
        mh = Math.round(mw * (9 / 16));
      }
      pt.setSize(mw, mh);
      // 구운 프레임은 절반 크기 8비트 그림으로 (실제로는 PNG 한 장 = 파일로)
      const tw = Math.max(8, Math.round(mw / 2));
      const th = Math.max(8, Math.round(mh / 2));
      if (tw !== fw || th !== fh) {
        for (const t of frames) t.dispose();
        frames.length = 0;
        for (let i = 0; i < NF; i++) frames.push(ldrTarget(tw, th));
        fw = tw;
        fh = th;
        restart();
      }
      // 굽기: 이 프레임 시각에 맞춰 놓고 샘플을 다 모을 때까지 → 찍고 → 다음 프레임
      if (doneT < 0) {
        pt.time = f / FPS;
        pt.shutter = blur ? 0.5 / FPS : 0;
        pt.max = spp;
        pt.sample(r, big ? 4 : 2);
        if (pt.done) {
          // 찍기 직전에 노이즈 제거 (실제 제작은 OIDN) — 적은 샘플로도 깨끗한 프레임
          show.draw(r, clean ? dn.run(r, pt) : pt.tex, { target: frames[f]! });
          f++;
          baked = Math.max(baked, f);
          pt.reset();
          if (f >= NF) {
            doneT = 0;
            f = NF - 1;
          }
        }
      }
      // 화면: 왼쪽 위 = 지금 굽는 프레임 · 오른쪽 위 = 구운 것 재생 · 아래 = 찍은 프레임 줄
      r.getClearColor(clear);
      const ca = r.getClearAlpha();
      r.setRenderTarget(null);
      r.setClearColor(0x0c1017, 1);
      r.clear();
      r.setClearColor(clear, ca);
      const top = h - pad;
      show.draw(r, pt.tex, { rect: [pad, top - mh, mw, mh] });
      const pw = w - mw - pad * 3;
      const ph = Math.round(pw * (9 / 16));
      const px = mw + pad * 2;
      if (baked > 0) show.draw(r, frames[Math.floor(playT * FPS) % baked]!.texture, { view: 3, rect: [px, top - ph, pw, ph] });
      const N = 8;
      const sw = (w - pad * (N + 1)) / N;
      const sh = Math.min(stripH, sw * (9 / 16));
      // 프레임 줄은 큰 화면 바로 아래 (아래에서 잰 y)
      const stripY = Math.max(pad, top - mh - pad - sh);
      const first = Math.max(0, baked - N);
      for (let i = 0; i < N && first + i < baked; i++) show.draw(r, frames[first + i]!.texture, { view: 3, rect: [Math.round(pad + i * (sw + pad)), Math.round(stripY), Math.round(sw), Math.round(sh)] });
      const done = doneT >= 0;
      tags = [
        { text: done ? `굽기 끝 — ${NF}장` : `프레임 ${f + 1}/${NF} · 샘플 ${pt.n}/${spp}`, x: pad / w + 0.01, y: pad / h + 0.01, ax: 0, ay: 0, big: true, bg: done ? 'rgba(40,150,90,0.9)' : undefined },
        { text: baked ? `구운 영상 재생 (${FPS}fps)` : '재생할 프레임 기다리는 중', x: px / w + 0.01, y: pad / h + 0.01, ax: 0, ay: 0 },
        { text: big ? `찍은 프레임 ${baked}장 → ffmpeg 로 mp4` : `찍은 ${baked}장`, x: 0.02, y: 1 - stripY / h + 0.01, ax: 0, ay: 0 },
      ];
      if (big) tags.push({ text: blur ? '셔터 180° — 빛줄마다 다른 순간이라 공이 저절로 번진다' : '모션 블러 끔 — 프레임마다 한 순간', x: px / w + 0.01, y: (pad + ph) / h + 0.03, ax: 0, ay: 0 });
      overlay.draw(r, w, h, tags);
    },
    controls: [
      { type: 'range', label: '프레임마다 모을 샘플', min: 4, max: 128, step: 4, value: 24, on: (v) => ((spp = v), restart()) },
      { type: 'toggle', label: '모션 블러 (셔터 열린 동안의 아무 순간)', value: true, on: (v) => ((blur = v), restart()) },
      { type: 'range', label: '빛이 튀는 횟수', min: 1, max: 8, step: 1, value: 5, on: (v) => ((pt.bounces = clamp(v, 1, 8)), restart()) },
      { type: 'toggle', label: '찍기 전에 노이즈 제거', value: true, on: (v) => ((clean = v), restart()) },
      { type: 'button', label: '처음부터 다시 굽기', on: () => restart() },
    ],
    dispose() {
      pt.dispose();
      dn.dispose();
      show.dispose();
      overlay.dispose();
      for (const t of frames) t.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기)](https://ai-techstudio.web.app/ai/t/i545.md) `i545` · [노이즈 제거 (적은 샘플 → 깨끗하게 · OIDN)](https://ai-techstudio.web.app/ai/t/i546.md) `i546` · [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82`
- 다음에 해 볼 기술: [내레이션 동기화 (낱말 시각표)](https://ai-techstudio.web.app/ai/t/i81.md) `i81` · [감독 투어 + 프레임 단위 녹화](https://ai-techstudio.web.app/ai/t/i89.md) `i89` · [3D 를 따라가는 이름표 · 지시선](https://ai-techstudio.web.app/ai/t/i38.md) `i38`
- 참고 문서: [Puppeteer — page.screenshot](https://pptr.dev/api/puppeteer.page.screenshot) · [FFmpeg Formats Documentation (image2pipe)](https://ffmpeg.org/ffmpeg-formats.html) · [three-gpu-pathtracer (GitHub)](https://github.com/gkjohnson/three-gpu-pathtracer)
