# AI 꾸러미 — 스톱모션 · 2프레임에 한 번 — Stop motion / animating on twos
> 그리는 시간을 12fps 로 끊고 장마다 씨앗 무작위로 살짝 흔들어(boiling), 매끈한 움직임을 점토 · 종이 오리기 같은 뚝뚝 끊긴 손맛으로 바꾼다.  
> 견본: https://ai-techstudio.web.app/#t/i123

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

## 주문서

### 만들어 줘: 스톱모션 · 2프레임에 한 번 — Stop motion / animating on twos

#### 1. 목표
종이 오리기 화풍 장면을 스톱모션 느낌으로 보여 줘 — 시간을 12fps 로 끊고, 장마다 살짝 다르게 흔들어서. 분위기는 종이 오리기 (그림자 있는 색종이).

#### 2. 핵심 기술 용어
- **Stop motion / animating on twos** — 두 프레임에 한 장 — 12fps 로 끊기
- **Time quantization (floor(t·fps)/fps)** — 시간을 계단으로 자르기
- **Line boil (per-frame jitter)** — 장마다 다르게 살짝 흔들림

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

#### 4. 조건
- 시간만 계단으로 자르고 렌더 루프는 그대로 (입력 · 화면 반응은 매끈하게)
- 흔들림 무작위는 장 번호 씨앗으로 — 같은 장 안에서 흔들리면 지글거린다
- 흔들림 크기는 아주 작게 (±0.8 · 각도 ±0.05)
- fps 조절(견본 12, 6 ~ 24)과 매끈한 60fps 와 나란히 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽은 종이 별이 매끈하게 폴짝, 오른쪽은 같은 장면이 뚝뚝 끊겨 움직인다
- 오른쪽은 장이 넘어갈 때마다 해 · 구름 · 언덕 가장자리가 살짝 다르게 떨린다
- fps 를 6 으로 내리면 더 뚝뚝, 24 로 올리면 매끈에 가까워진다

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

## 원리
- 장 번호 fi = ⌊t × fps⌋, 그릴 시간 = fi / fps. 같은 장 동안은 움직임이 멈춰 있다가 다음 장에 뚝 넘어간다.
- 흔들림(boil): 장 번호를 씨앗으로 한 무작위로 자리 · 각도를 ±0.8 정도 흔든다 — 같은 장 안에선 같고, 장이 바뀌면 다르다.
- 종이 느낌: 조각마다 그림자(오프셋 1.5 · 2, 흐림 2)와 가위로 자른 듯 들쭉날쭉한 가장자리.
- 렌더링은 60fps 그대로 두고 「보여 주는 시간」 만 끊는다 — 입력 반응은 매끈하게 남는다.

## 핵심 코드 — 시간 계단 + 장 번호 씨앗 흔들림
(발췌: demos/demosMotionB.ts i123 을 정리)
```ts
function rng(seed: number): () => number {       // 씨앗 무작위 (같은 씨앗 = 같은 수열)
  let s = Math.abs(Math.floor(seed * 9301 + 49297)) % 2147483647;
  if (s <= 0) s += 2147483646;
  return () => (s = (s * 16807) % 2147483647) / 2147483647;
}

function draw(g: CanvasRenderingContext2D, t: number) {
  const fi = Math.floor(t * fps);    // 장 번호 (fps = 12)
  const tt = fi / fps;               // 이 장의 시간 — 다음 장까지 멈춰 있다
  const R = rng(fi * 7 + 3);         // 장마다 다른 흔들림, 같은 장 안에서는 같음
  const j = () => (R() - 0.5) * 1.6 * u;
  // 종이 그림자
  g.shadowColor = 'rgba(80,50,20,.3)';
  g.shadowOffsetX = 1.5 * u; g.shadowOffsetY = 2 * u; g.shadowBlur = 2 * u;
  // 해: 도는 각도에도 흔들림
  g.save();
  g.translate(hw * 0.78 + j(), h * 0.24 + j());
  g.rotate(tt * 0.4 + (R() - 0.5) * 0.08);
  drawSun(g);
  g.restore();
  // 언덕: 가위로 자른 듯 가장자리를 장마다 다르게
  g.beginPath(); g.moveTo(0, h);
  for (let x = 0; x <= hw + 6; x += 6 * u)
    g.lineTo(x, h * 0.68 - Math.sin(x / (hw * 0.35) + 1) * 10 * u + (R() - 0.5) * 1.2 * u);
  g.lineTo(hw, h); g.closePath(); g.fill();
}
```

## 흔한 실수 · 확인 목록
- [ ] **Math.random 으로 흔들면 같은 장 안에서도 지글거린다** — 장 번호를 씨앗으로 한 무작위를 매번 새로 만들어 쓴다.
- [ ] **렌더 루프 자체를 12fps 로 낮추면 단추 · 입력도 굼떠진다** — 그리기는 그대로 두고 그림에 넣는 시간만 계단으로 자른다.
- [ ] **흔들림이 크면 지진처럼 보인다** — 위치 ±0.8 · 각도 ±0.05 정도로 아주 작게.

## 완성 기준 체크리스트
- [ ] 왼쪽은 종이 별이 매끈하게 폴짝, 오른쪽은 같은 장면이 뚝뚝 끊겨 움직인다
- [ ] 오른쪽은 장이 넘어갈 때마다 해 · 구름 · 언덕 가장자리가 살짝 다르게 떨린다
- [ ] fps 를 6 으로 내리면 더 뚝뚝, 24 로 올리면 매끈에 가까워진다

## 이 기술 정보
- id: `i123` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 계산은 같고 오히려 덜 바뀐다. 장이 안 바뀌면 다시 그리지 않아도 된다 (배터리 절약).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i123
- 쓰면 좋을 때: 손으로 만든 듯한 따뜻한 화풍이 필요할 때 / 종이 · 점토 그림체 장면 (매끈한 60fps 는 오히려 어색)
- 쓰지 말 때: 조작에 바로 반응해야 하는 캐릭터 · 커서 — 끊기면 굼떠 보인다. 대신 배경 · 연출에만 / 빠른 액션 — 12fps 는 빠른 움직임에서 뚝뚝 너무 튄다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i123 견본 항목 — `src/demos/demosMotionB.ts:2052`
```ts
  i123: {
    kind: '2d',
    caption: '같은 종이 오리기 장면 — 왼쪽 매끈한 60fps · 오른쪽 시간을 12fps 로 끊고 장마다 살짝 흔들어 뚝뚝 손맛',
    make() {
      let fps = 12;
      const paperScene = (g: G, ox: number, hw: number, h: number, tt: number, boil: number, u: number): void => {
        const R = rng(boil * 7 + 3);
        const j = (): number => (boil ? (R() - 0.5) * 1.6 * u : 0);
        g.save();
        g.beginPath();
        g.rect(ox, 0, hw, h);
        g.clip();
        g.translate(ox, 0);
        // 종이 하늘
        g.fillStyle = '#f6e3c4';
        g.fillRect(0, 0, hw, h);
        g.shadowColor = 'rgba(80,50,20,.3)';
        g.shadowOffsetX = 1.5 * u;
        g.shadowOffsetY = 2 * u;
        g.shadowBlur = 2 * u;
        // 해
        g.save();
        g.translate(hw * 0.78 + j(), h * 0.24 + j());
        g.rotate(tt * 0.4 + (boil ? (R() - 0.5) * 0.08 : 0));
        star(g, 0, 0, 15 * u, 0, 10, 0.72);
        g.fillStyle = '#ffb547';
        g.fill();
        g.restore();
        // 구름
        const cx = ((tt * 12 * u) % (hw + 60 * u)) - 30 * u;
        g.fillStyle = '#fffaf0';
        g.beginPath();
        g.arc(cx + j(), h * 0.2, 9 * u, 0, TAU);
        g.arc(cx + 10 * u + j(), h * 0.17, 11 * u, 0, TAU);
        g.arc(cx + 21 * u + j(), h * 0.21, 8 * u, 0, TAU);
        g.fill();
        // 언덕 두 겹 (가위로 자른 듯 들쭉날쭉)
        const hill = (y0: number, amp: number, col: string, ph: number): void => {
          g.fillStyle = col;
          g.beginPath();
          g.moveTo(0, h);
          for (let x = 0; x <= hw + 6; x += 6 * u) g.lineTo(x, y0 - Math.sin(x / (hw * 0.35) + ph) * amp + (boil ? (R() - 0.5) * 1.2 * u : Math.sin(x * 1.7) * 0.6 * u));
          g.lineTo(hw, h);
          g.closePath();
          g.fill();
        };
        hill(h * 0.68, 10 * u, '#9fd36a', 1);
        hill(h * 0.82, 7 * u, '#6dbb4f', 3);
        // 주인공: 종이 별이 폴짝폴짝
        const P = 3.2;
        const p = fract(tt / P);
        const x = -20 * u + p * (hw + 40 * u);
        const hp = fract(p * 4);
        const y = h * 0.74 - Math.sin(hp * Math.PI) * h * 0.26;
        const sq = hp < 0.1 || hp > 0.92 ? 0.8 : 1;
        g.save();
        g.translate(x + j(), y + j());
        g.rotate(Math.sin(hp * TAU) * 0.25 + (boil ? (R() - 0.5) * 0.1 : 0));
        g.scale(1 / sq, sq);
        star(g, 0, -13 * u, 15 * u, 0, 5, 0.5);
        g.fillStyle = '#ff6fa3';
        g.fill();
        g.shadowColor = 'transparent';
        g.fillStyle = '#3a2340';
        g.beginPath();
        g.arc(-4 * u, -14 * u, 1.6 * u, 0, TAU);
        g.arc(4 * u, -14 * u, 1.6 * u, 0, TAU);
        g.fill();
        g.strokeStyle = '#3a2340';
        g.lineWidth = 1.3 * u;
        g.beginPath();
        g.arc(0, -11.5 * u, 2.5 * u, 0.3, Math.PI - 0.3);
        g.stroke();
        g.restore();
        g.restore();
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          const hw = w / 2;
          paperScene(g, 0, hw, h, t, 0, u);
          const fi = Math.floor(t * fps);
          paperScene(g, hw, hw, h, fi / fps, fi + 1, u);
          splitLabels(g, w, h, u, '매끈 60fps', `스톱모션 ${fps}fps`);
          // 필름 칸 표시
          g.fillStyle = 'rgba(40,20,10,.55)';
          for (let i = 0; i < 6; i++) g.fillRect(hw + 8 * u + i * 12 * u, h - 9 * u, 7 * u, 4 * u);
          g.fillStyle = '#ffd23f';
          g.fillRect(hw + 8 * u + (fi % 6) * 12 * u, h - 9 * u, 7 * u, 4 * u);
        },
        controls: [{ type: 'range', label: '오른쪽 초당 장 수', min: 4, max: 30, step: 1, value: 12, on: (v) => (fps = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [이음새 없는 반복 고리](https://ai-techstudio.web.app/ai/t/i120.md) `i120`
- 다음에 해 볼 기술: [만화 펜선 + 스크린톤](https://ai-techstudio.web.app/ai/t/i335.md) `i335` · [2D 잔상 꼬리 · 대시 잔상](https://ai-techstudio.web.app/ai/t/i298.md) `i298`
- 참고 문서: [Wikipedia — Stop motion](https://en.wikipedia.org/wiki/Stop_motion)
