# AI 꾸러미 — 타임라인 연출 + 프레임 효과음 — Timeline-driven animation (frame as a function)
> 그림을 「프레임 번호의 함수」 로 만들고 장면 · 키프레임 · 효과음을 한 시간표에 두어, 재생 막대가 지나는 프레임에서 그림을 계산하고 소리를 울린다.  
> 견본: https://ai-techstudio.web.app/#t/i127

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

## 주문서

### 만들어 줘: 타임라인 연출 + 프레임 효과음 — Timeline-driven animation (frame as a function)

#### 1. 목표
짧은 문제 → 정답 컷신 (150 프레임 · 30fps)을(를) 타임라인 연출로 만들어 줘 — 그림은 프레임 번호의 함수로, 장면 · 키프레임 · 효과음을 한 시간표에, 재생 막대가 지나갈 때 소리. 분위기는 밝은 만화 컷신.

#### 2. 핵심 기술 용어
- **Timeline-driven animation (frame as a function)** — 그림 = 프레임 번호의 함수 — 어느 시점이든 바로 계산
- **Keyframes + sound cues** — 키프레임과 효과음 신호를 같은 시간표에
- **Remotion / HyperFrames style** — 시간을 옮겨 놓고 찍는 방식

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 그림은 프레임 번호만으로 계산 — 앞 프레임에 기대는 누적 상태 금지
- 효과음은 「지난 프레임 < 신호 ≤ 이번 프레임」 일 때 한 번만, 고리 반복(끝 → 처음)도 처리
- 소리는 사용자가 켤 때만 (브라우저 자동 재생 막힘 · 첫 터치 뒤 AudioContext)
- 시간표에 장면 · 키 · 소리를 같은 축으로, 지금 프레임 근처의 키를 강조
- 재생 빠르기 조절 (0.25 ~ 2배)

#### 5. 완성 기준 (이게 보이면 성공)
- 재생 막대가 시간표를 지나며 위 미리보기가 그 프레임 그림으로 바뀐다 (별 등장 → 「3 + 4 = ?」 → 「7」 쾅 → 도장 · 꽃가루)
- ♪ 표시를 지나는 순간 효과음이 울리고 「♪ 쾅!」 말풍선이 뜬다 (소리 켰을 때)
- 빠르기를 바꿔도 소리가 그림과 같은 프레임에 맞는다

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

## 원리
- 그림을 「지금 프레임 f」 만 받아 그리는 함수로 만든다 — 앞 프레임 상태를 쌓지 않으니 어느 프레임으로 건너뛰어도 같은 그림.
- 각 요소는 자기 시작 프레임 기준 진행 (f − f0) / 길이 로 움직인다 (별 0~24 · 「3 + 4」 30 · 「=」 52 · 「?」 74 · 「7」 쾅 96 · 도장 104).
- 효과음은 [프레임, 이름, 재생] 목록. 지난 프레임 → 이번 프레임 사이에 그 프레임을 지났으면 울린다 (끝에서 처음으로 넘어갈 때도).
- 시간표 화면: 장면 띠 · 키프레임 마름모 · 소리 ♪ 를 같은 가로 축에, 재생 막대가 그 위를 지나간다.

## 핵심 코드 — 프레임 함수 + 효과음 신호 (지나간 프레임 감지)
(발췌: demos/demosMotionB.ts i127 을 정리)
```ts
const FPS = 30, TOTAL = 150;
const SFX: [number, string, () => void][] = [
  [2, '휙', () => snd.noise(0.25, 0.12, 1800)],
  [30, '뿅', () => snd.tone(520, 0.15, 'square', 0.12, 2)],
  [96, '쾅', () => (snd.tone(110, 0.3, 'sine', 0.4, 0.5), snd.noise(0.12, 0.2, 200))],
  [104, '딩동', () => snd.tone(988, 0.25, 'triangle', 0.2)],
];
let ph = 0, lastF = -1;

function draw(g: CanvasRenderingContext2D, dt: number) {
  ph += (dt * speed * FPS) / TOTAL;
  const f = Math.floor((ph - Math.floor(ph)) * TOTAL);  // 지금 프레임 (고리)
  if (f !== lastF) {
    for (const [sf, , play] of SFX)                        // 지난 프레임 → 이번 프레임 사이를 지났나
      if ((lastF < sf && f >= sf) || (lastF > f && f >= sf)) play(); // 뒤는 끝 → 처음 넘김
    lastF = f;
  }
  renderFrame(g, f);
}

/** 그림 = 프레임의 함수 — 어느 f 로 건너뛰어도 같은 그림 */
function renderFrame(g: CanvasRenderingContext2D, f: number) {
  const sk = easeOutBack(f / 24);                          // 0~24: 별이 날아와 자리
  drawStar(g, lerp(-20, 22, sk), 20);
  ([['3 + 4', 30], ['=', 52], ['?', 74]] as const).forEach(([s, f0], i) => {
    if (f < f0 || (i === 2 && f >= 96)) return;
    const k = easeOutBack((f - f0) / 10);                  // 시작 프레임 기준 10프레임 동안 톡
    drawText(g, s, xs[i], cy, k);
  });
  if (f >= 96) drawText(g, '7', xs[2], cy, lerp(2.4, 1, Math.min(1, (f - 96) / 6))); // 쾅
}
```

## 흔한 실수 · 확인 목록
- [ ] **앞 프레임 상태를 쌓아 그리면 건너뛰기 · 되감기에서 그림이 틀어진다** — 모든 요소를 (f − 시작 프레임) 으로만 계산한다.
- [ ] **「f === 신호」 로 비교하면 느린 기기에서 프레임을 건너뛰어 소리가 빠진다** — 지난 프레임과 이번 프레임 사이를 지났는지로 본다.
- [ ] **페이지가 열리자마자 소리를 내면 브라우저가 막는다** — 소리는 단추로 켤 때 AudioContext 를 만들고 resume.
- [ ] **효과음 최고점이 그림의 타격 순간과 어긋나면 어색하다** — 소리를 들어 보고 최고점을 타격 프레임에 맞춘다 (사이트에서 이름만 보고 골랐다 지적받음).

## 완성 기준 체크리스트
- [ ] 재생 막대가 시간표를 지나며 위 미리보기가 그 프레임 그림으로 바뀐다 (별 등장 → 「3 + 4 = ?」 → 「7」 쾅 → 도장 · 꽃가루)
- [ ] ♪ 표시를 지나는 순간 효과음이 울리고 「♪ 쾅!」 말풍선이 뜬다 (소리 켰을 때)
- [ ] 빠르기를 바꿔도 소리가 그림과 같은 프레임에 맞는다

## 이 기술 정보
- id: `i127` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 프레임마다 그림을 새로 계산한다. 소리는 Web Audio 로 합성 (자동 재생 없이 단추로 켬).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i127
- 쓰면 좋을 때: 정해진 순서의 연출(컷신 · 소개 · 영상)을 만들고 고칠 때 — 시간표를 보며 맞추기 / 영상으로 뽑을 장면 (프레임마다 찍기)
- 쓰지 말 때: 사용자 입력으로 갈리는 게임 장면 — 대신 상태 기계 / 물리 시뮬레이션 — 프레임 함수로 바로 계산할 수 없다 (앞에서부터 돌려야 함)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i127 견본 항목 — `src/demos/demosMotionB.ts:2508`
```ts
  i127: {
    kind: '2d',
    caption: '장면 · 키프레임 · 효과음을 한 시간표에 — 재생 막대가 지나는 프레임 번호로 그림을 「계산해서」 그리고, ♪ 표시에서 소리',
    make() {
      const FPS = 30;
      const TOTAL = 150;
      const snd = new Blip();
      const SFX: [number, string, () => void][] = [
        [2, '휙', () => snd.noise(0.25, 0.12, 1800)],
        [30, '뿅', () => snd.tone(520, 0.15, 'square', 0.12, 2)],
        [52, '뿅', () => snd.tone(620, 0.15, 'square', 0.12, 2)],
        [74, '뿅', () => snd.tone(720, 0.15, 'square', 0.12, 2)],
        [96, '쾅', () => (snd.tone(110, 0.3, 'sine', 0.4, 0.5), snd.noise(0.12, 0.2, 200))],
        [104, '딩동', () => (snd.tone(988, 0.25, 'triangle', 0.2), setTimeout(() => snd.tone(784, 0.35, 'triangle', 0.2), 150))],
      ];
      const SCENES: [number, number, string, string][] = [
        [0, 30, '등장', '#5fd6ff'],
        [30, 96, '문제', '#a77bff'],
        [96, 150, '정답', '#ff6fa3'],
      ];
      const KEYS = [0, 24, 30, 52, 74, 96, 104, 140];
      let lastF = -1;
      let speed = 1;
      let ph = 0;
      // 그림 = 프레임의 함수 (시간을 옮겨 놓고 찍기)
      const renderFrame = (g: G, f: number, x: number, y: number, w: number, h: number, u: number): void => {
        g.save();
        g.beginPath();
        rr(g, x, y, w, h, 8 * u);
        g.clip();
        const gr = g.createLinearGradient(0, y, 0, y + h);
        gr.addColorStop(0, '#2a3a8a');
        gr.addColorStop(1, '#141c4a');
        g.fillStyle = gr;
        g.fillRect(x, y, w, h);
        const cx = x + w / 2;
        const cy = y + h / 2;
        const fade = 1 - clamp01((f - 138) / 12);
        g.globalAlpha = fade;
        // 별이 날아와 자리 잡음 (0~24)
        const sk = easeOutBack(f / 24);
        const sx = lerp(x - 20 * u, x + 22 * u, sk);
        g.save();
        g.translate(sx, y + 20 * u);
        g.rotate((1 - clamp01(f / 24)) * -4 + Math.sin(f / 8) * 0.1);
        star(g, 0, 0, 10 * u);
        g.fillStyle = '#ffd23f';
        g.fill();
        g.restore();
        // 3 + 4 = ? 한 기호씩 (30 · 52 · 74)
        const parts: [string, number][] = [
          ['3 + 4', 30],
          ['=', 52],
          ['?', 74],
        ];
        const fs = Math.min(h * 0.3, w * 0.13);
        const xs = [cx - fs * 1.1, cx + fs * 0.55, cx + fs * 1.35];
        parts.forEach(([s, f0], i) => {
          const k = easeOutBack((f - f0) / 10);
          if (f < f0) return;
          const isQ = i === 2;
          if (isQ && f >= 96) return;
          g.save();
          g.translate(xs[i]!, cy);
          g.scale(k, k);
          txt(g, s, 0, 0, fs, isQ ? '#ffd23f' : '#fff', 'center', TF, 400);
          g.restore();
        });
        // 7 이 쾅 (96)
        if (f >= 96) {
          const k = clamp01((f - 96) / 6);
          const sc = lerp(2.4, 1, k);
          const shake = f < 104 ? Math.sin(f * 3) * 2 * u : 0;
          g.save();
          g.translate(xs[2]! + shake, cy);
          g.scale(sc, sc);
          g.globalAlpha = fade * k;
          txt(g, '7', 0, 0, fs, '#ff6fa3', 'center', TF, 400);
          g.restore();
          g.globalAlpha = fade;
          // 도장 원 + 꽃가루
          if (f >= 104) {
            const k2 = clamp01((f - 104) / 8);
            g.strokeStyle = `rgba(255,111,163,${1 - k2 * 0.4})`;
            g.lineWidth = 2.5 * u;
            g.beginPath();
            g.arc(xs[2]!, cy, fs * (0.55 + k2 * 0.1), 0, TAU);
            g.stroke();
            const R = rng(5);
            for (let i = 0; i < 24; i++) {
              const a = R() * TAU;
              const sp = (30 + R() * 60) * u;
              const tt = (f - 104) / FPS;
              const px = xs[2]! + Math.cos(a) * sp * tt;
              const py = cy + Math.sin(a) * sp * tt + 60 * u * tt * tt;
              g.fillStyle = ['#ffd23f', '#5fd6ff', '#ff6fa3', '#44f0a8'][i % 4]!;
              g.fillRect(px, py, 3 * u, 3 * u);
            }
          }
        }
        g.restore();
      };
      return {
        draw(g, w, h, _t, dt) {
          const u = scaleOf(w, h);
          ph += (dt * speed * FPS) / TOTAL;
          const f = Math.floor(fract(ph) * TOTAL);
          if (f !== lastF) {
            for (const [sf, , play] of SFX) if ((lastF < sf && f >= sf) || (lastF > f && f >= sf)) play();
            lastF = f;
          }
          bg(g, w, h, '#151a3a', '#0a0d22');
          const pad = 8 * u;
          const ph2 = h * 0.52;
          renderFrame(g, f, pad, pad, w - pad * 2, ph2 - pad, u);
          // 효과음 말풍선 (미리보기 위)
          for (const [sf, name] of SFX) {
            const k = (f - sf) / 12;
            if (k >= 0 && k < 1) {
              const a = 1 - k;
              g.globalAlpha = a;
              pill(g, `♪ ${name}!`, w - pad - 6 * u, pad + 12 * u + k * -6 * u, 8 * u, '#ff5c8a', '#fff', 'right');
              g.globalAlpha = 1;
            }
          }
          txt(g, `프레임 ${String(f).padStart(3, '0')} / ${TOTAL} · ${FPS}fps`, pad + 6 * u, ph2 - 8 * u, 7 * u, 'rgba(255,255,255,.75)', 'left', F, 700);
          // 시간표
          const tx = pad + 34 * u;
          const tw = w - pad - tx;
          const ty = ph2 + 8 * u;
          const th = (h - ty - pad) / 3;
          const X = (fr: number): number => tx + (fr / TOTAL) * tw;
          const rowsN = ['장면', '키', '소리'];
          rowsN.forEach((nm, i) => {
            const y = ty + th * i;
            rr(g, tx, y + 2 * u, tw, th - 4 * u, 4 * u);
            g.fillStyle = 'rgba(255,255,255,.06)';
            g.fill();
            txt(g, nm, pad + 2 * u, y + th / 2, 7.5 * u, 'rgba(255,255,255,.7)', 'left', F, 800);
          });
          for (const [a, b, nm, col] of SCENES) {
            const on = f >= a && f < b;
            rr(g, X(a) + 1, ty + 3 * u, X(b) - X(a) - 2, th - 6 * u, 4 * u);
            g.fillStyle = on ? col : col + '66';
            g.fill();
            txt(g, nm, (X(a) + X(b)) / 2, ty + th / 2, 7 * u, on ? '#0a0d22' : '#fff', 'center', F, 800);
          }
          for (const kf of KEYS) {
            const x = X(kf);
            const y = ty + th * 1.5;
            const near = Math.abs(f - kf) < 4;
            const s = (near ? 4.5 : 3.2) * u;
            g.fillStyle = near ? '#ffd23f' : '#cfd8ff';
            g.beginPath();
            g.moveTo(x, y - s);
            g.lineTo(x + s, y);
            g.lineTo(x, y + s);
            g.lineTo(x - s, y);
            g.closePath();
            g.fill();
          }
          for (const [sf, name] of SFX) {
            const x = X(sf);
            const y = ty + th * 2.5;
            const hit = f >= sf && f - sf < 10;
            const r = (hit ? 6 : 4) * u;
            g.fillStyle = hit ? '#ff5c8a' : 'rgba(255,92,138,.55)';
            g.beginPath();
            g.arc(x, y, r, 0, TAU);
            g.fill();
            if (!(w < 400 && name === '딩동')) txt(g, '♪', x, y, 6 * u, '#fff', 'center', F, 900);
          }
          // 재생 막대
          const px = X(f);
          g.fillStyle = '#ffd23f';
          g.fillRect(px - 1 * u, ty, 2 * u, th * 3);
          g.beginPath();
          g.moveTo(px - 4 * u, ty - 4 * u);
          g.lineTo(px + 4 * u, ty - 4 * u);
          g.lineTo(px, ty + 1 * u);
          g.fill();
          if (snd.on) pill(g, '♪ 소리 켬', pad + 6 * u, pad + 12 * u, 7 * u, '#ff5c8a', '#fff', 'left');
        },
        controls: [
          { type: 'range', label: '재생 빠르기', min: 0.25, max: 2, step: 0.25, value: 1, on: (v) => (speed = v) },
          { type: 'button', label: '♪ 효과음 켜기 · 끄기', on: () => snd.toggle() },
        ] as Control[],
        dispose() {
          snd.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [휩 팬 · 줌 통과 · 매치 컷](https://ai-techstudio.web.app/ai/t/i106.md) `i106` · [Manim식 수식 변환](https://ai-techstudio.web.app/ai/t/i117.md) `i117`
- 다음에 해 볼 기술: [배경음악 넣기 (반복 · 페이드 · 켜고 끄기)](https://ai-techstudio.web.app/ai/t/u77.md) `u77` · [정답 아르페지오 (조성 맞춤)](https://ai-techstudio.web.app/ai/t/i141.md) `i141`
- 참고 문서: [Remotion — 공식 사이트](https://www.remotion.dev/)
