# AI 꾸러미 — 감독 투어 + 프레임 단위 녹화 — Playwright
> 전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상  
> 견본: https://ai-techstudio.web.app/#t/i89

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

## 주문서

### 만들어 줘: 감독 투어 + 프레임 단위 녹화 — Playwright

#### 1. 목표
게임 소개 영상에 「감독 투어 + 프레임 단위 녹화」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상

#### 2. 핵심 기술 용어
- **Playwright**
- **ffmpeg**
- **fps**

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 화면 코드와 계산 코드를 나눠서 (계산은 순수 함수)
- 한 프레임 16ms 안 — 오래 걸리는 계산은 나눠서

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i89` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 공통 · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i89
- 쓰면 좋을 때: 쓰면 좋을 곳 — 게임 소개 영상 · 축제 홍보 · 튜토리얼 영상

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i89 견본 항목 — `src/demos/demosAirsup.ts:1128`
```ts
  i89: {
    kind: '2d',
    caption: '대본(카메라 구도 · 커서 이동 · 실제 클릭)을 1/60초씩 진행하며 한 장씩 찍어 ffmpeg 로 — 느린 컴퓨터도 끊김 없는 영상',
    make() {
      type Shot = { n: string; t: number; d: number; cx: number; cy: number; z: number; col: string };
      const SHOTS: Shot[] = [
        { n: 'hero', t: 0, d: 0.01, cx: 52, cy: 30, z: 1, col: '#5b8def' },
        { n: 'cut', t: 2.4, d: 1.6, cx: 50, cy: 30, z: 1.55, col: '#45c3a4' },
        { n: 'ox', t: 5.0, d: 1.5, cx: 30, cy: 22, z: 2.1, col: '#46c8ff' },
        { n: 'ch4', t: 7.6, d: 1.5, cx: 30, cy: 38, z: 2.1, col: '#ffa23a' },
        { n: 'fire', t: 10.2, d: 1.5, cx: 62, cy: 30, z: 2.2, col: '#ff6a5a' },
        { n: 'plume', t: 12.8, d: 1.6, cx: 76, cy: 30, z: 1.35, col: '#b48cff' },
        { n: 'hero', t: 15.4, d: 1.6, cx: 52, cy: 30, z: 1, col: '#5b8def' },
      ];
      const BTN = ['전체', '단면', '산소', '메탄', '불꽃'];
      const CLICKS: { t: number; b: number }[] = [
        { t: 2.3, b: 1 },
        { t: 4.9, b: 2 },
        { t: 7.5, b: 3 },
        { t: 10.1, b: 4 },
        { t: 15.3, b: 0 },
      ];
      const DUR = 18;
      const FPS = 60;
      const TOTAL = DUR * FPS;
      const SLOW = 0.42; // 느린 컴퓨터: 실제 1초에 영상 0.42초만 찍힘
      let pace = SLOW;
      let T0 = 0;
      let lastT = 0;
      const st = { show: true };
      const camAt = (T: number): { cx: number; cy: number; z: number; shot: Shot } => {
        let prev = SHOTS[0]!;
        let cur = SHOTS[0]!;
        for (let i = 0; i < SHOTS.length; i++) if (SHOTS[i]!.t <= T) {
          cur = SHOTS[i]!;
          prev = SHOTS[Math.max(0, i - 1)]!;
        }
        const k = ease(clamp((T - cur.t) / cur.d, 0, 1));
        return { cx: prev.cx + (cur.cx - prev.cx) * k, cy: prev.cy + (cur.cy - prev.cy) * k, z: prev.z + (cur.z - prev.z) * k, shot: cur };
      };
      const stateAt = (T: number): { view: number; follow: number } => {
        let view = 0;
        let follow = -1;
        for (const c of CLICKS) {
          if (c.t > T) break;
          if (c.b <= 1) view = c.b;
          if (c.b === 0) follow = -1;
          if (c.b >= 2) follow = c.b;
          if (c.b >= 2) view = 1;
        }
        return { view, follow };
      };
      const rr = (g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void => {
        g.beginPath();
        g.roundRect(x, y, w, h, r);
      };
      return {
        draw(g, w, h, t) {
          if (t < lastT) T0 = t;
          lastT = t;
          const tv = ((t - T0) * pace) % DUR;
          const frame = Math.floor(tv * FPS);
          const T = frame / FPS; // 화면은 프레임 단위로 딱딱 넘어간다
          const small = w < 420;
          const U = clamp(Math.min(w / 300, h / 190), 0.85, 3.2);
          const f = (px: number, bold = false): string => `${bold ? 800 : 600} ${px * U}px ${FONT}`;
          // 바탕
          const bg = g.createLinearGradient(0, 0, 0, h);
          bg.addColorStop(0, '#16233f');
          bg.addColorStop(1, '#0a1020');
          g.fillStyle = bg;
          g.fillRect(0, 0, w, h);
          const pad = 6 * U;
          const tlH = h * (small ? 0.3 : 0.27);
          const S = { x: pad, y: pad, w: w * (small ? 0.64 : 0.6), h: h - tlH - pad * 3 };
          const Rx = S.x + S.w + pad;
          const Rw = w - Rx - pad;

          /* ---------- 화면: 시뮬레이션 + 카메라 구도 + 커서 ---------- */
          g.save();
          rr(g, S.x, S.y, S.w, S.h, 6 * U);
          g.clip();
          const sg = g.createLinearGradient(0, S.y, 0, S.y + S.h);
          sg.addColorStop(0, '#22345a');
          sg.addColorStop(1, '#0e1628');
          g.fillStyle = sg;
          g.fillRect(S.x, S.y, S.w, S.h);
          const cam = camAt(T);
          const { view, follow } = stateAt(T);
          const k = (S.w / 100) * cam.z;
          g.save();
          g.translate(S.x + S.w / 2, S.y + S.h * 0.44);
          g.scale(k, k);
          g.translate(-cam.cx, -cam.cy);
          // 탱크
          const dimOf = (b: number): number => (follow < 0 || follow === b ? 1 : 0.25);
          const tank = (x: number, y: number, col: string, a: number): void => {
            g.globalAlpha = 0.35 + 0.65 * a;
            g.fillStyle = col;
            rr(g, x - 6, y - 7, 12, 14, 5);
            g.fill();
            g.globalAlpha = 1;
          };
          tank(12, 20, '#4f86c8', dimOf(2));
          tank(12, 40, '#c8874f', dimOf(3));
          // 관 + 흐르는 펄스
          const flow = (pts: [number, number][], col: string, a: number): void => {
            g.lineCap = 'round';
            g.lineJoin = 'round';
            g.strokeStyle = '#8b93a1';
            g.lineWidth = 3.2;
            g.beginPath();
            pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
            g.stroke();
            if (view === 1) {
              g.strokeStyle = col;
              g.globalAlpha = 0.25 + 0.75 * a;
              g.lineWidth = 1.8;
              g.setLineDash([2.2, 2.2]);
              g.lineDashOffset = -T * 9;
              g.stroke();
              g.setLineDash([]);
              g.globalAlpha = 1;
            }
          };
          flow([[18, 20], [40, 20], [40, 26], [47, 26]], '#46c8ff', dimOf(2));
          flow([[18, 40], [40, 40], [40, 34], [47, 34]], '#ffa23a', dimOf(3));
          // 엔진 (연소실 + 목 + 노즐 종)
          const engine = new Path2D('M46 23 L56 23 Q58 23 59 27 L60 28.5 Q61 25 70 20 L70 40 Q61 35 60 31.5 L59 33 Q58 37 56 37 L46 37 Z');
          g.fillStyle = '#9aa1ac';
          g.fill(engine);
          if (view === 1) {
            g.fillStyle = '#d4d8de';
            g.fill(new Path2D('M46 30 L56 30 L59 30 L60 30 L70 30 L70 40 Q61 35 60 31.5 L59 33 Q58 37 56 37 L46 37 Z'));
            g.fillStyle = `rgba(255,${follow === 4 ? 200 : 150},90,${follow < 0 || follow === 4 ? 0.95 : 0.35})`;
            g.fill(new Path2D('M47 31 L56 31 L59.2 31 L60 31 L69 31 L69 38 Q61 34 60 32 L59 33.5 Q58 36 56 36 L47 36 Z'));
          }
          // 배기 + 다이아몬드
          const pa = follow < 0 || follow === 4 ? 1 : 0.4;
          const pl = g.createLinearGradient(70, 0, 100, 0);
          pl.addColorStop(0, `rgba(180,150,255,${0.75 * pa})`);
          pl.addColorStop(1, 'rgba(180,150,255,0)');
          g.fillStyle = pl;
          g.beginPath();
          g.moveTo(70, 21);
          g.quadraticCurveTo(85, 23, 100, 25);
          g.lineTo(100, 35);
          g.quadraticCurveTo(85, 37, 70, 39);
          g.fill();
          for (let i = 0; i < 4; i++) {
            g.fillStyle = `rgba(255,236,210,${(0.9 - i * 0.18) * pa})`;
            g.beginPath();
            g.ellipse(75 + i * 6.5, 30, 1.6 - i * 0.2, 2.6 - i * 0.35, 0, 0, Math.PI * 2);
            g.fill();
          }
          g.restore();
          // 구도 이름
          g.font = f(8.5, true);
          g.textBaseline = 'top';
          g.fillStyle = 'rgba(8,14,30,0.6)';
          const tag = `카메라 구도 · ${cam.shot.n}`;
          const tw = g.measureText(tag).width;
          rr(g, S.x + 5 * U, S.y + 5 * U, tw + 10 * U, 14 * U, 7 * U);
          g.fill();
          g.fillStyle = cam.shot.col;
          g.fillText(tag, S.x + 10 * U, S.y + 7.5 * U);
          // 단추 줄
          const bw = Math.min(40 * U, (S.w - 12 * U) / BTN.length - 3 * U);
          const bh = 13 * U;
          const by = S.y + S.h - bh - 6 * U;
          const bx0 = S.x + (S.w - (bw + 3 * U) * BTN.length + 3 * U) / 2;
          const btnC = (i: number): [number, number] => [bx0 + i * (bw + 3 * U) + bw / 2, by + bh / 2];
          g.font = f(7.5, true);
          g.textAlign = 'center';
          g.textBaseline = 'middle';
          BTN.forEach((b, i) => {
            const on = (i <= 1 && view === i && follow < 0) || (i <= 1 && i === 1 && view === 1) || (i >= 2 && follow === i);
            g.fillStyle = on ? '#ffd166' : 'rgba(255,255,255,0.13)';
            rr(g, bx0 + i * (bw + 3 * U), by, bw, bh, bh / 2);
            g.fill();
            g.fillStyle = on ? '#2a1c00' : '#e8eefc';
            g.fillText(b, btnC(i)[0], btnC(i)[1] + 0.5);
          });
          g.textAlign = 'left';
          // 커서: 클릭 0.7초 전부터 그 단추로 이동
          let cx = S.x + S.w * 0.9;
          let cy = S.y + S.h * 1.1;
          for (const c of CLICKS) {
            const [tx, ty] = btnC(c.b);
            if (T >= c.t) {
              cx = tx;
              cy = ty;
            } else if (T > c.t - 0.7) {
              const q = ease((T - (c.t - 0.7)) / 0.7);
              cx += (tx - cx) * q;
              cy += (ty - cy) * q;
              break;
            } else break;
          }
          if (T > 1.6 && T < 16.6) {
            for (const c of CLICKS) {
              const a = T - c.t;
              if (a < 0 || a > 0.45) continue;
              g.strokeStyle = `rgba(255,255,255,${1 - a / 0.45})`;
              g.lineWidth = 2 * U;
              g.beginPath();
              g.arc(cx, cy, (5 + a * 40) * U, 0, Math.PI * 2);
              g.stroke();
            }
            g.save();
            g.translate(cx, cy);
            g.scale(U * 0.75, U * 0.75);
            g.fillStyle = '#fff';
            g.strokeStyle = '#0b0f17';
            g.lineWidth = 1.3;
            const ar = new Path2D('M2 1.5v16.2l4.3-4.1 2.9 6.6 2.7-1.2-2.9-6.5h6.1z');
            g.fill(ar);
            g.stroke(ar);
            g.restore();
          }
          g.restore();
          g.strokeStyle = 'rgba(255,255,255,0.25)';
          g.lineWidth = 1;
          rr(g, S.x + 0.5, S.y + 0.5, S.w - 1, S.h - 1, 6 * U);
          g.stroke();

          /* ---------- 오른쪽: 녹화 사슬 ---------- */
          g.font = f(8.5, true);
          g.textBaseline = 'top';
          g.fillStyle = '#ffe7a8';
          g.fillText('프레임 단위 녹화', Rx, S.y + 1);
          const steps = small ? ['frame(1/60초)', '찍기', 'ffmpeg'] : ['대본 한 칸 진행', 'frame(1/60초)', '화면 찍기 (JPEG)', 'ffmpeg → mp4'];
          const chainY = S.y + 16 * U;
          const sh = 13 * U;
          const gap = 5 * U;
          const ph = (tv * FPS) % 1;
          steps.forEach((s, i) => {
            const y = chainY + i * (sh + gap);
            const act = Math.floor(ph * steps.length) === i;
            g.fillStyle = act ? 'rgba(255,209,102,0.95)' : 'rgba(255,255,255,0.1)';
            rr(g, Rx, y, Rw, sh, 4 * U);
            g.fill();
            g.fillStyle = act ? '#2a1c00' : '#dfe7f7';
            g.font = f(7.2, true);
            g.textBaseline = 'middle';
            g.fillText(s, Rx + 5 * U, y + sh / 2 + 0.5);
            if (i < steps.length - 1) {
              g.fillStyle = 'rgba(255,255,255,0.4)';
              g.beginPath();
              g.moveTo(Rx + Rw / 2 - 3 * U, y + sh + 1);
              g.lineTo(Rx + Rw / 2 + 3 * U, y + sh + 1);
              g.lineTo(Rx + Rw / 2, y + sh + gap - 1);
              g.fill();
            }
          });
          let y2 = chainY + steps.length * (sh + gap) + 2 * U;
          g.textBaseline = 'top';
          g.font = f(11, true);
          g.fillStyle = '#ffffff';
          g.fillText(`${String(frame).padStart(4, '0')} / ${TOTAL}`, Rx, y2);
          y2 += 14 * U;
          g.font = f(7, false);
          g.fillStyle = '#9fb3d9';
          g.fillText(`영상 ${T.toFixed(2)}초`, Rx, y2);
          y2 += 10 * U;
          g.fillStyle = '#ffb3a8';
          g.fillText(`실제 ${(T / pace).toFixed(1)}초 걸림`, Rx, y2);
          y2 += 12 * U;
          // 필름 띠: 방금 찍은 프레임들 (모두 같은 1/60초 간격)
          if (y2 + 14 * U < S.y + S.h) {
            const n = small ? 4 : 6;
            const fw = (Rw - (n - 1) * 2 * U) / n;
            const fh = Math.min(fw * 0.62, S.y + S.h - y2);
            for (let i = 0; i < n; i++) {
              const fr = frame - (n - 1 - i);
              if (fr < 0) continue;
              const shot = camAt(fr / FPS).shot;
              const x = Rx + i * (fw + 2 * U);
              g.fillStyle = shot.col;
              g.globalAlpha = i === n - 1 ? 1 : 0.55;
              rr(g, x, y2, fw, fh, 2 * U);
              g.fill();
              g.globalAlpha = 1;
              if (!small && fh > 10 * U) {
                g.font = f(5.5, true);
                g.fillStyle = '#0a1020';
                g.fillText(String(fr), x + 2 * U, y2 + 2 * U);
              }
            }
          }

          /* ---------- 아래: 타임라인 ---------- */
          const TL = { x: pad + 30 * U, y: h - tlH - pad * 0.5, w: w - pad * 2 - 30 * U, h: tlH };
          const X = (s: number): number => TL.x + (s / DUR) * TL.w;
          const rowH = TL.h / 3.2;
          g.font = f(7, true);
          g.textBaseline = 'middle';
          g.fillStyle = '#9fb3d9';
          g.fillText('카메라', pad, TL.y + rowH * 0.5);
          g.fillText('커서', pad, TL.y + rowH * 1.5);
          g.fillText('시간', pad, TL.y + rowH * 2.5);
          SHOTS.forEach((s, i) => {
            const end = i + 1 < SHOTS.length ? SHOTS[i + 1]!.t : DUR;
            const x0 = X(s.t);
            const x1 = X(end);
            g.fillStyle = s.col;
            g.globalAlpha = T >= s.t && T < end ? 1 : 0.45;
            rr(g, x0 + 1, TL.y + 2, x1 - x0 - 2, rowH - 4, 3 * U);
            g.fill();
            g.globalAlpha = 1;
            if (x1 - x0 > 24 * U) {
              g.fillStyle = '#0a1020';
              g.font = f(6.5, true);
              g.fillText(s.n, x0 + 4 * U, TL.y + rowH / 2);
            }
            // 이동 구간 (빗금)
            g.fillStyle = 'rgba(255,255,255,0.35)';
            g.fillRect(x0 + 1, TL.y + rowH - 5, Math.max(1, X(s.t + s.d) - x0 - 1), 2);
          });
          for (const c of CLICKS) {
            g.strokeStyle = 'rgba(255,255,255,0.45)';
            g.lineWidth = 2 * U;
            g.beginPath();
            g.moveTo(X(c.t - 0.7), TL.y + rowH * 1.5);
            g.lineTo(X(c.t), TL.y + rowH * 1.5);
            g.stroke();
            const x = X(c.t);
            const yy = TL.y + rowH * 1.5;
            g.fillStyle = T >= c.t ? '#ffd166' : 'rgba(255,209,102,0.45)';
            g.beginPath();
            g.moveTo(x, yy - 4 * U);
            g.lineTo(x + 4 * U, yy);
            g.lineTo(x, yy + 4 * U);
            g.lineTo(x - 4 * U, yy);
            g.fill();
            if (!small) {
              g.font = f(6, true);
              g.fillStyle = '#ffe7a8';
              g.fillText(BTN[c.b]!, x + 6 * U, yy);
            }
          }
          // 눈금: 1초마다, 프레임 눈금은 크게 볼 때만
          g.fillStyle = 'rgba(255,255,255,0.5)';
          for (let s = 0; s <= DUR; s++) {
            const x = X(s);
            g.fillRect(x, TL.y + rowH * 2.1, 1, s % 5 === 0 ? 7 * U : 4 * U);
            if (s % 5 === 0) {
              g.font = f(6, false);
              g.fillText(`${s}s`, x + 2, TL.y + rowH * 2.75);
            }
          }
          if (!small) {
            g.fillStyle = 'rgba(255,255,255,0.18)';
            const fx = TL.w / TOTAL;
            if (fx * 6 > 1.5) for (let i = 0; i <= TOTAL; i += 6) g.fillRect(X(i / FPS), TL.y + rowH * 2.1, 1, 2 * U);
          }
          // 재생 머리
          const px = X(T);
          g.fillStyle = '#ff5a6a';
          g.fillRect(px - 1, TL.y - 2, 2, TL.h * 0.95);
          g.beginPath();
          g.moveTo(px - 4 * U, TL.y - 4);
          g.lineTo(px + 4 * U, TL.y - 4);
          g.lineTo(px, TL.y + 2);
          g.fill();
          if (st.show && !small) {
            g.font = f(6.5, true);
            g.fillStyle = '#ffb3c0';
            g.textBaseline = 'bottom';
            g.fillText(`frame ${frame}`, Math.min(px + 4 * U, w - 50 * U), TL.y - 3);
          }
        },
        controls: [
          { type: 'range', label: '컴퓨터 빠르기 (실제 1초에 찍는 영상 초)', min: 0.1, max: 1, step: 0.02, value: SLOW, on: (v) => (pace = v) },
          { type: 'toggle', label: '재생 머리에 프레임 번호', value: true, on: (v) => (st.show = v) },
        ] satisfies Control[],
      };
    },
  }
```
