# AI 꾸러미 — 게임 소개 영상 30초

## 주문서

### 한 장면 만들기: 게임 소개 영상 30초

#### 1. 목표
수학 퍼즐 게임의 30초 소개 영상을 코드로 만들어 줘 — ① 제목이 입자로 모여 나타나고 ② 폰 화면 속에서 손가락이 실제로 게임을 해 보이고 ③ 규칙을 흐름도로 한 번에 보여 준 뒤 ④ 로고와 「지금 해 보기」로 끝. 모든 장면 전환은 음악 박에 맞추고, 헤드리스 브라우저로 한 프레임씩 구워 mp4 로. 분위기는 어두운 보라 바탕 · 네온 (밤 분위기).
아래 기술 10개를 한 장면에 엮어 줘. 한 번에 다 만들지 말고 1번부터 차례로 쌓되, 단계마다 화면에서 확인할 수 있게.

#### 2. 들어갈 기술 (쌓는 순서)
##### 1) 2D 입자 글자 (글자 → 입자 → 다른 글자) — Particle text / text-to-particles
- 역할: 첫 장면 — 게임 제목이 입자로 흩어졌다 모임
- 핵심: 자리 뽑기: 보이지 않는 캔버스에 글자를 꽉 차게 그리고 getImageData 로 알파 > 128 인 픽셀만 고른다. 간격 = √(칠해진 넓이 ÷ 입자 수) 로 두면 어느 글자든 점 수가 비슷하다.
- 확인: 글자가 점으로 또렷하게 읽히고, 2~3초마다 점들이 흩어졌다가 다음 글자로 모인다
- 확인: 「흩어졌다 모이기」를 끄면 점들이 곧장 다음 자리로 흐르고, 켜면 한 번 터진 뒤 모인다
##### 2) 글자 차례 등장 (키네틱 타이포) — Kinetic typography
- 역할: 부제 · 자막이 글자마다 차례로 튀어 오름
- 핵심: 문장을 글자 배열로 나누고, 글자 폭(measureText)을 더해 가운데 정렬 자리를 먼저 정한다.
- 확인: 글자가 왼쪽부터 하나씩 튀어 올라 살짝 넘쳤다 자리 잡는다
- 확인: 내려앉는 순간 글자가 가로로 퍼졌다 통통 돌아온다
##### 3) 폰 틀 목업 (기울어진 폰 · 화면 속 스크롤 · 손가락 표시) — Device mockup (perspective · rotateY)
- 역할: 기울어진 폰 틀 — 화면 속 게임이 밀리고 흐름
- 핵심: 틀: perspective 를 준 부모 안에서 폰을 rotateY −16° ± 10°, rotateX 6° 로 천천히 흔든다. 그림자는 기울기 반대로 밀고 가로로 조금 줄여 바닥에 붙은 느낌.
- 확인: 폰이 천천히 기울며 흔들리고, 그림자와 유리 반사 띠가 기울기를 따라 움직인다
- 확인: 손가락이 밀어 올렸다 놓으면 화면이 스르르 더 흐르다 멈춘다
##### 4) 가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대) — Scripted cursor / product demo animation
- 역할: 폰 화면 속 손가락 대본 · 누름 물결 · 누르는 곳으로 확대
- 핵심: 대본 = [언제, 어디서, 어디로] 이동 목록 + [언제, 무엇을] 클릭 목록. 매 프레임 「지금 몇 초째인가」로 커서 자리 · 단추 상태 · 배지 숫자를 바로 계산한다.
- 확인: 커서가 곡선으로 가서 멈칫한 뒤 누르고, 단추가 눌리며 노란 고리가 퍼진다
- 확인: 「담기」를 누르면 점이 장바구니로 날아가고 배지 숫자가 통 튀며 올라간다
##### 5) 공유 요소 전환 (FLIP · 목록 → 상세) — FLIP animation (First, Last, Invert, Play)
- 역할: 게임 고르기 → 게임 화면으로 이어지는 전환
- 핵심: First: 바꾸기 전에 그림 · 제목의 위치와 크기를 잰다. Last: 배치를 상세 화면으로 실제로 바꾸고 다시 잰다.
- 확인: 카드를 누르면 그 카드의 그림이 끊김 없이 커져 화면 위쪽을 꽉 채우고, 제목이 그 아래 큰 글씨 자리로 날아간다
- 확인: 전환 켜기/끄기를 바꾸면 끈 쪽은 화면이 뚝 바뀌고, 켠 쪽은 어느 카드를 눌렀는지 눈으로 따라갈 수 있다
##### 6) 흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점) — Animated flowchart / diagram
- 역할: 규칙을 흐름도로 — 상자가 튀어나오고 선이 그려짐
- 핵심: 선 그리기: 경로 길이 L 을 재서 stroke-dasharray 를 「L L」로 — 대시 하나가 선 전체. dashoffset 을 L → 0 으로 줄이면 시작에서 끝으로 그려진다.
- 확인: 상자가 시작부터 차례로 통 튀어나오고, 상자 사이 선이 시작에서 끝 방향으로 그려진 뒤 화살촉이 꽂힌다
- 확인: 노란 점이 「답 입력 → 맞았나? → 예/아니오 → 다시」를 돌고, 지나간 상자 테두리가 잠깐 노랗게 굵어진다
##### 7) 음악 반응 시각화 (주파수 띠 · 엔벨로프 · 박 고리) — AnalyserNode · getByteFrequencyData
- 역할: 배경이 음악에 반응 — 저음에 숨쉬는 빛 · 박 고리
- 핵심: 띠 나누기: 40Hz ~ 8kHz 를 로그 간격 32띠로 (fc = 40 × 200^((b+0.5)/32)). 세기는 dB 로 바꿔 −54 ~ 0 dB 를 0 ~ 1 로 — 그대로 쓰면 저음만 크고 고음은 안 보인다. 높은 띠는 조금 올려 준다.
- 확인: 킥이 칠 때마다 가운데 원이 커졌다 스르르 줄고 고리가 하나 퍼진다
- 확인: 하이햇이 칠 때 작은 반짝이가 늘고, 막대가 원 둘레로 주파수 순서대로 움직인다
##### 8) 3D 입체 글자 · 로고 등장
- 역할: 끝 장면 입체 로고 등장
- 핵심: 두께 있는 글자가 돌며 착지 · 반짝
- 확인: 견본처럼 「두께 있는 글자가 돌며 착지 · 반짝」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 9) 타임라인 연출 + 프레임 효과음 — Timeline-driven animation (frame as a function)
- 역할: 장면 · 키프레임 · 효과음을 한 시간표로
- 핵심: 그림을 「지금 프레임 f」 만 받아 그리는 함수로 만든다 — 앞 프레임 상태를 쌓지 않으니 어느 프레임으로 건너뛰어도 같은 그림.
- 확인: 재생 막대가 시간표를 지나며 위 미리보기가 그 프레임 그림으로 바뀐다 (별 등장 → 「3 + 4 = ?」 → 「7」 쾅 → 도장 · 꽃가루)
- 확인: ♪ 표시를 지나는 순간 효과음이 울리고 「♪ 쾅!」 말풍선이 뜬다 (소리 켰을 때)
##### 10) 영상으로 굽기 (헤드리스 브라우저 → mp4) — mp4
- 역할: 헤드리스 브라우저로 한 프레임씩 찍어 소리와 함께 mp4
- 핵심: 장면 코드를 헤드리스 브라우저로 1/30초씩 시계를 돌려 프레임을 찍고 · 목소리 · 음악과 ffmpeg 로 묶어 mp4 — 홍보 영상 · 설명 영상
- 확인: 견본처럼 「장면 코드를 헤드리스 브라우저로 1/30초씩 시계를 돌려 프레임을 찍고 · 목소리 · 음악과 ffmpeg 로 묶어 mp4 — 홍보 영상 · 설명 영상」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 2D + 3D · 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 아래 설명 · 용어는 웹 · 캔버스 2D 기준이야. 웹 · HTML/CSS 에 있는 대응 기능으로 바꿔서 구현해 줘.

#### 4. 조건 (공통)
- 장면 시간표는 표 하나로: 0~4초 제목 · 4~16초 폰 시연 · 16~24초 흐름도 · 24~30초 로고. 모든 움직임은 영상 시각 t 하나에서 계산 (같은 t = 같은 그림)
- 음악은 미리 분석해 박 시각 · 저음 세기를 표로 — 장면이 바뀌는 시각을 가장 가까운 박에 맞춘다
- 폰 시연은 대본(시각 · 손가락 자리 · 동작) 배열로, 3번 넘게 누르지 않기 — 핵심 장면만
- 세로(1080×1920 쇼츠)와 가로(1920×1080) 둘 다 같은 장면 코드에서 — 배치만 바꿈
- 끝 장면 로고 · 문구는 최소 3초 정지 (읽을 시간)
- 글자 자리는 화면 크기가 바뀔 때만 다시 뽑고 결과를 기억해 둘 것 (매 프레임 getImageData 금지)
- 입자 상태는 Float32Array 로 — 객체 배열 수천 개 만들지 않기
- 줄 자리는 글자를 다 놓았을 때 기준으로 미리 정한다 (움직이는 중에 옆 글자가 밀리지 않게)
- 글자마다 지연 = 순서 × 간격, 간격은 조절판으로 (견본 0.09초, 0.02 ~ 0.25)
- 폰 기울기는 작게 (rotateY 30° 안) — 화면 내용이 읽혀야 한다
- 화면 속 움직임은 translateY 로만, 내용은 overflow:hidden 인 둥근 화면 안에
- 커서 움직임 · 클릭 · 결과를 모두 「대본의 시각」에서 계산할 것 (setTimeout 을 줄줄이 걸지 않는다 — 되감기 · 속도 조절 · 영상 굽기가 깨진다)
- 이동은 곧은 선 대신 살짝 휜 호 + inOut 이징, 누르기 전 짧게 멈춤
- FLIP 네 단계를 지킬 것: 바꾸기 전 측정 → 배치 실제로 바꾸기 → 다시 측정 → 차이를 transform 으로 거꾸로 놓고 풀기

#### 5. 완성 기준 (전체)
- 제목 입자가 첫 박에 맞춰 모이고, 폰 시연에서 무엇을 눌렀는지 손가락 · 물결로 따라갈 수 있다
- 흐름도가 차례로 그려지며 규칙이 15초 안에 읽힌다
- 장면 전환이 음악 박과 맞고, 구운 mp4 가 정확한 30fps · 30초다
- 같은 코드로 가로 · 세로 영상이 둘 다 나온다
- 기술마다 켬/끔 단추가 있어 하나씩 꺼 보며 그 기술이 맡은 차이를 확인할 수 있다
- 콘솔 오류 0 · 폰에서도 끊기지 않는다

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

## 참고 1. 2D 입자 글자 (글자 → 입자 → 다른 글자) — 글자 → 점 자리 뽑기 + 스프링
```ts
function sampleWord(word: string, w: number, h: number, n: number): number[] {
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  const g = c.getContext('2d', { willReadFrequently: true })!;
  let size = h * 0.6;
  g.font = '900 ' + size + 'px sans-serif';
  const tw = g.measureText(word).width;
  if (tw > w * 0.86) size *= (w * 0.86) / tw;           // 폭에 맞춰 줄이기
  g.font = '900 ' + size + 'px sans-serif';
  g.textAlign = 'center'; g.textBaseline = 'middle';
  g.fillText(word, w / 2, h / 2);
  const d = g.getImageData(0, 0, w, h).data;
  const on = (x: number, y: number) => d[((y | 0) * w + (x | 0)) * 4 + 3] > 128;
  let area = 0;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (on(x, y)) area++;
  const step = Math.max(1, Math.sqrt(area / n));         // 어느 글자든 점 수 ≈ n
  const pts: number[] = [];
  for (let y = step / 2; y < h; y += step)
    for (let x = step / 2; x < w; x += step) if (on(x, y)) pts.push(x, y);
  return pts;
}

// 매 프레임 — k: 스프링 세기, c: 마찰 (감쇠비 0.55 → 살짝 출렁)
const c = 2 * Math.sqrt(k) * 0.55;
for (let i = 0; i < N; i++) {
  vx[i] += (k * (tx[i] - px[i]) - c * vx[i]) * dt;
  vy[i] += (k * (ty[i] - py[i]) - c * vy[i]) * dt;
  px[i] += vx[i] * dt;
  py[i] += vy[i] * dt;
}
```
- [ ] **글자마다 점 수가 크게 달라 어떤 글자는 성기다** — 간격을 고정하면 「★」는 점이 적고 「스튜디오」는 넘친다. 칠해진 넓이를 먼저 세어 간격 = √(넓이 ÷ n) 으로 글자마다 정한다.
- [ ] **바뀔 때 점들이 한쪽으로 줄지어 미끄러져 심심하다** — 자리 순서(왼쪽 → 오른쪽)대로 짝지으면 모두 같은 방향으로 움직인다. 섞은 순서로 짝지으면 엇갈려 건너간다.
- [ ] **탭을 다녀오면 입자가 화면 밖으로 튀어 나간다** — dt 가 몇 초가 되면 스프링이 폭발한다. dt 를 1/30 초로 막는다.
- [ ] **글꼴이 늦게 내려오면 첫 글자만 기본 글꼴로 뽑힌다** — document.fonts.ready 뒤에 자리를 뽑거나, 글꼴이 바뀌면 다시 뽑는다.

## 참고 2. 글자 차례 등장 (키네틱 타이포) — 글자마다 지연 · 넘침 · 착지 찌그러짐 · 떨어지기
```ts
const outBack = (x: number, s = 1.9) => { const v = Math.min(1, Math.max(0, x)) - 1; return 1 + (s + 1) * v * v * v + s * v * v; };
const p = tt % 4.3; // 한 주기
chars.forEach((c, i) => {
  const cx = xs[i];                          // 미리 정한 글자 가운데 자리
  const tin = p - (0.2 + i * gap);           // 들어오기 시작 (gap = 0.09초)
  const tout = p - (3.4 + i * gap * 0.7);    // 나가기 시작
  if (tin < 0) return;
  const k = Math.min(1, tin / 0.55);
  let y = 78 - (1 - outBack(k, 2.4)) * 50;
  let sc = 0.2 + (1 - 0.2) * outBack(k, 2.2);
  let rot = (1 - (1 - Math.pow(1 - k, 3))) * (i % 2 ? 0.6 : -0.6);
  let a = Math.min(1, tin / 0.12);
  let sx = 1, sy = 1;
  const land = tin - 0.35;                   // 착지 뒤 0.5초 찌그러짐
  if (land > 0 && land < 0.5) { const q = Math.exp(-land * 9) * Math.sin(land * 30); sx = 1 + q * 0.25; sy = 1 - q * 0.25; }
  if (tout > 0) { y += tout * tout * 260; rot += tout * (i % 2 ? 2.2 : -2.2); a *= Math.max(0, 1 - tout * 1.6); sc *= 1 - tout * 0.2; }
  if (a <= 0) return;
  g.save();
  g.globalAlpha = a;
  g.translate(cx, y + 18); g.rotate(rot); g.scale(sc * sx, sc * sy); g.translate(0, -18); // 아래쪽 축
  g.lineJoin = 'round';
  g.strokeStyle = '#1a1446'; g.lineWidth = 6; g.strokeText(c, 0, 0);
  g.fillStyle = cols[i % cols.length]; g.fillText(c, 0, 0);
  g.restore();
});
```
- [ ] **글자를 움직일 때마다 줄을 다시 재면 옆 글자가 출렁인다** — 자리는 다 놓인 문장 기준으로 한 번 정하고, 움직임은 그 자리를 축으로만 준다.
- [ ] **가운데를 축으로 크기를 바꾸면 공중에서 부푸는 것처럼 보인다** — 글자 아래쪽(견본 +18)으로 옮겨 놓고 크기 · 회전을 준 뒤 되돌린다.
- [ ] **모든 글자가 같은 쪽으로 기울면 단조롭다** — 홀짝 글자를 반대로 (i % 2 ? 0.6 : −0.6).

## 참고 3. 폰 틀 목업 (기울어진 폰 · 화면 속 스크롤 · 손가락 표시) — 관성 · 고무줄 식 + 폰 기울기
```ts
// 놓은 뒤 t 초 동안 더 흐른 거리 (지수 감쇠)
const fling = (v: number, t: number, tau = 0.45) => v * tau * (1 - Math.exp(-t / tau));

// 끝을 넘겨 x 만큼 당겼을 때 실제로 내려오는 거리 (d = 화면 높이)
const rubber = (x: number, d: number) => (1 - 1 / ((x * 0.55) / d + 1)) * d;

function frame(clock: number) {
  const s = script(clock % CYCLE);              // 대본 시각 → 스크롤 · 손가락 · 단계
  feed.style.transform = 'translateY(' + (-s.scroll) + 'px)';
  const ry = -16 + Math.sin(clock * 0.55) * 10; // 천천히 흔들리는 기울기
  phone.style.transform = 'rotateY(' + ry + 'deg) rotateX(6deg)';
  shadow.style.transform = 'translateX(' + (-ry * 0.35) + 'px)';
  glare.style.backgroundPosition = (50 + ry * 3.2) + '% 0'; // 반사 띠가 미끄러짐
  finger.style.opacity = String(s.fingerAlpha);
  finger.style.transform = 'scale(' + (s.pressed ? 0.82 : 1) + ')';
}
```
- [ ] **폰 위쪽이 화면 밖으로 잘린다** — rotateX 로 기울이면 원근 때문에 위가 커진다. 폰 둘레에 여백(위아래 5% 넘게)을 둔다.
- [ ] **누르는 카드가 이미 화면 밖이다** — 스크롤된 뒤의 자리를 계산하지 않고 원래 자리를 누르면 손가락이 허공을 누른다. 손가락 y = 카드 자리 − 그 순간 스크롤.
- [ ] **관성이 끝에서 딱 끊긴다** — 직선으로 줄이면 멈추는 순간이 티 난다. 지수 감쇠는 끝으로 갈수록 저절로 느려진다.
- [ ] **3D 기울기에서 글자가 흐려진다** — 변형 중엔 래스터 한 장을 늘려 그리기도 한다. 멈춘 장면을 오래 보여 줄 땐 기울기를 0 으로 풀거나 will-change 를 끈다.

## 참고 4. 가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대) — 대본에서 커서 자리 계산 (휜 호 + inOut)
```ts
type P2 = [number, number];
// [시작 초, 끝 초, 출발, 도착]
const MOVES: [number, number, P2, P2][] = [
  [0.25, 1.0, [262, 166], [228, 56]],
  [1.35, 1.95, [228, 56], [228, 112]],
];
const CLICKS = [1.15, 2.1];

const seg = (p: number, a: number, b: number) => Math.min(1, Math.max(0, (p - a) / (b - a)));
const inOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);

function cursorAt(p: number): P2 {
  let pos: P2 = MOVES[0][2];
  for (const [t0, t1, a, b] of MOVES) {
    if (p < t0) break;                       // 아직 시작 안 한 이동
    const k = inOut(seg(p, t0, t1));
    const nx = -(b[1] - a[1]), ny = b[0] - a[0];
    const nl = Math.hypot(nx, ny) || 1;
    const arc = Math.sin(Math.PI * k) * 10;  // 사람 손처럼 옆으로 살짝 휜 길
    pos = [a[0] + (b[0] - a[0]) * k + (nx / nl) * arc, a[1] + (b[1] - a[1]) * k + (ny / nl) * arc];
  }
  return pos;
}

// 확대: 누르기 직전에만 목표 1.35, 실제 값은 부드럽게 따라감
const zt = CLICKS.some((c) => p > c - 0.45 && p < c + 0.2) ? 1.35 : 1;
z += (zt - z) * (1 - Math.exp(-dt * 6));
```
- [ ] **계속 확대해 두면 결과가 화면 밖에 있다** — 처음엔 줄곧 1.5 배로 따라갔더니 장바구니 배지가 잘려 보이지 않았다. 누르는 순간 근처에만 확대하고 물러나 결과를 보여 준다.
- [ ] **setTimeout 으로 이어 붙이면 속도 · 되감기가 깨진다** — 탭을 숨겼다 오면 타이머가 몰려 터진다. 대본을 시각 목록으로 두고 매 프레임 지금 시각으로 계산한다.
- [ ] **결과가 누름과 같은 순간에 나오면 원인이 안 읽힌다** — 날아가는 점 · 배지 변화를 0.3~0.5초 늦게 — 눈이 커서에서 결과로 옮겨 갈 시간.
- [ ] **확대 초점이 커서를 딱 붙어 따라가면 어지럽다** — 초점 · 배율 모두 지수 감쇠(1 − e^(−dt·k))로 늦게 따라가게 하고, 이동 범위를 화면 안으로 가둔다.

## 참고 5. 공유 요소 전환 (FLIP · 목록 → 상세) — FLIP — 재고, 바꾸고, 다시 재고, 거꾸로 놓고 풀기
```ts
function flip(el: HTMLElement, change: () => void, dur = 700, keepAspect = false) {
  const f = el.getBoundingClientRect();          // First
  change();                                      // 배치를 실제로 바꾼다 (클래스 · 부모 바꾸기)
  const l = el.getBoundingClientRect();          // Last
  const sy = f.height / l.height;
  const s = keepAspect ? [sy, sy] : [f.width / l.width, sy];  // 글자는 같은 배율로
  el.style.transformOrigin = '0 0';
  el.animate(
    [
      { transform: 'translate(' + (f.left - l.left) + 'px,' + (f.top - l.top) + 'px) scale(' + s[0] + ',' + s[1] + ')' }, // Invert
      { transform: 'none' },                                                                                              // Play
    ],
    { duration: dur, easing: 'cubic-bezier(.2,1.25,.35,1)' },  // 살짝 지나쳤다 돌아옴
  );
}

// 카드를 누르면 그림과 제목을 따로 FLIP
card.onclick = () => {
  flip(thumb, () => thumb.classList.add('open'));
  flip(title, () => title.classList.add('open'), 700, true);
};
```
- [ ] **측정 사이에 배치를 안 바꾸면 차이가 0 이 된다** — First 와 Last 사이에 실제 배치 변경(클래스 · 스타일)이 있어야 한다. 바꾸는 코드를 requestAnimationFrame 뒤로 미루면 두 측정 값이 같아져 아무 일도 없다.
- [ ] **글자를 가로 · 세로 다른 배율로 키우면 찌그러진다** — 상자 비율이 바뀌면 sx ≠ sy 다. 견본처럼 그림과 제목을 따로 FLIP 하고 제목은 높이 비율 하나로 키운다.
- [ ] **모서리 둥글기도 같이 늘어난다** — scale 은 border-radius 까지 늘린다. 둥근 모서리가 중요하면 clip-path: inset(… round r) 를 따로 애니하거나 안쪽 요소에 반대 배율을 준다.
- [ ] **움직이는 중에 다시 누르면 엉뚱한 자리에서 시작한다** — 진행 중인 애니를 먼저 끝내고(transform 지우기) 측정한다. 견본은 go() 첫 줄에서 finish() 를 부른다.
- [ ] **닫힐 때 다른 카드 밑으로 숨는다** — 움직이는 동안 z-index 를 올려 두고 끝나면 되돌린다.

## 참고 6. 흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점) — 선 그리기 + 선 따라 점 옮기기
```ts
const paths = [...svg.querySelectorAll<SVGPathElement>('path.edge')];
const lens = paths.map((p) => p.getTotalLength());
paths.forEach((p, i) => (p.style.strokeDasharray = lens[i] + ' ' + lens[i]));

// 화살촉: 끝 바로 앞 점 → 끝 점 방향으로 돌려 끝에 놓기
paths.forEach((p, i) => {
  const a = p.getPointAtLength(lens[i] - 1.5), b = p.getPointAtLength(lens[i]);
  heads[i].setAttribute('transform', 'translate(' + b.x + ' ' + b.y + ') rotate(' + Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + ')');
});

function drawEdge(i: number, k: number) {             // k: 0 → 1
  paths[i].style.strokeDashoffset = String(lens[i] * (1 - k));
  heads[i].style.opacity = String(Math.min(1, Math.max(0, (k - 0.85) / 0.15)));
}

// 점: 경로 목록을 따라 걷기 (V = 빠르기, WAIT = 상자에서 머묾)
function dotAt(t: number, route: number[]): DOMPoint | null {
  for (const ei of route) {
    const dur = lens[ei] / V;
    if (t < dur) return paths[ei].getPointAtLength(lens[ei] * t / dur);
    t -= dur;
    if (t < WAIT) return paths[ei].getPointAtLength(lens[ei]);  // 상자에서 잠깐 머묾
    t -= WAIT;
  }
  return null;
}
```
- [ ] **marker-end 화살촉은 선이 그려지기 전부터 끝에 떠 있다** — marker 는 대시와 상관없이 경로 끝에 그려진다. 화살촉을 따로 그리고 선이 85% 넘게 그려지면 나타나게 했다.
- [ ] **화면에 붙기 전에 getTotalLength 를 부르면 0 이 나올 수 있다** — 요소가 문서에 들어간 뒤 재고, 0 이면 다음 프레임에 다시 잰다 (견본은 update 첫머리에서 확인).
- [ ] **모양을 확대 · 축소하면 dasharray 가 안 맞는다** — vector-effect 나 transform 으로 크기가 바뀌면 길이도 바뀐다. 길이를 다시 재거나 pathLength="1" 로 정규화한다.
- [ ] **점을 setInterval 로 한 칸씩 옮기면 속도 · 되감기가 깨진다** — 지금 시각에서 걸은 거리를 계산해 바로 자리를 구한다.

## 참고 7. 음악 반응 시각화 (주파수 띠 · 엔벨로프 · 박 고리) — 실시간 띠 세기 + 엔벨로프 + 박
```ts
const ana = ac.createAnalyser();
ana.fftSize = 2048;
ana.smoothingTimeConstant = 0;            // 부드럽게는 직접 (오름 · 내림 따로)
source.connect(ana);
const db = new Float32Array(ana.frequencyBinCount);
const NB = 32, env = new Float32Array(NB);
let prevBass = 0;

function frame(dt: number) {
  ana.getFloatFrequencyData(db);         // 칸마다 dB (−∞ ~ 0)
  const hz = ac.sampleRate / ana.fftSize; // 칸 하나의 폭
  const up = 1 - Math.exp(-dt / 0.01), dn = 1 - Math.exp(-dt / 0.22);
  for (let b = 0; b < NB; b++) {
    const lo = 40 * Math.pow(200, b / NB), hi = 40 * Math.pow(200, (b + 1) / NB);
    let s = -100, n = 0;
    for (let k = Math.floor(lo / hz); k <= Math.ceil(hi / hz); k++) { s = Math.max(s, db[k]); n++; }
    const v = Math.min(1, Math.max(0, (s + 54 + b * 0.45) / 54));
    env[b] += (v - env[b]) * (v > env[b] ? up : dn);
  }
  const bass = (env[0] + env[1] + env[2] + env[3] + env[4] + env[5]) / 6;
  if (bass - prevBass > 0.08) spawnRing();  // 박 = 저음이 훅 오름
  prevBass = bass;
}
```
- [ ] **막대가 대부분 안 움직이고 왼쪽 몇 개만 춤춘다** — FFT 칸은 주파수가 고르게 나뉘어 음악 에너지가 몰린 저음 쪽은 몇 칸뿐이다. 로그 간격 띠로 묶고 dB 로 바꾼다.
- [ ] **그림이 바들바들 떨려 어지럽다** — smoothingTimeConstant 하나로는 오름도 느려진다. 오름 10ms · 내림 0.2초짜리 엔벨로프를 직접 둔다.
- [ ] **구운 영상에서 그림과 소리가 어긋난다** — 프레임 찍기는 실시간보다 느려 AnalyserNode 가 다른 순간을 준다. OfflineAudioContext 로 미리 분석한 표를 프레임 시각으로 읽는다.
- [ ] **폰에서 소리 · 그림이 안 움직인다** — AudioContext 가 suspended 상태다. 첫 터치에서 ac.resume().
- [ ] **바탕 번쩍임이 너무 잦다** — 박 사이 최소 간격(0.15초)을 두고 번쩍 세기를 낮춘다 — 광과민 안전.

## 참고 8. 3D 입체 글자 · 로고 등장 — 
```ts

```


## 참고 9. 타임라인 연출 + 프레임 효과음 — 프레임 함수 + 효과음 신호 (지나간 프레임 감지)
```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.
- [ ] **효과음 최고점이 그림의 타격 순간과 어긋나면 어색하다** — 소리를 들어 보고 최고점을 타격 프레임에 맞춘다 (사이트에서 이름만 보고 골랐다 지적받음).

## 참고 10. 영상으로 굽기 (헤드리스 브라우저 → mp4) — 
```ts

```

## 들어간 기술 (하나씩 자세히)
- [2D 입자 글자 (글자 → 입자 → 다른 글자)](https://ai-techstudio.web.app/ai/t/i556.md) `i556`
- [글자 차례 등장 (키네틱 타이포)](https://ai-techstudio.web.app/ai/t/i90.md) `i90`
- [폰 틀 목업 (기울어진 폰 · 화면 속 스크롤 · 손가락 표시)](https://ai-techstudio.web.app/ai/t/i559.md) `i559`
- [가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대)](https://ai-techstudio.web.app/ai/t/i549.md) `i549`
- [공유 요소 전환 (FLIP · 목록 → 상세)](https://ai-techstudio.web.app/ai/t/i548.md) `i548`
- [흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점)](https://ai-techstudio.web.app/ai/t/i552.md) `i552`
- [음악 반응 시각화 (주파수 띠 · 엔벨로프 · 박 고리)](https://ai-techstudio.web.app/ai/t/i557.md) `i557`
- [3D 입체 글자 · 로고 등장](https://ai-techstudio.web.app/ai/t/i115.md) `i115`
- [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82`
