# Blockshot Script API v0.1

Blender의 `bpy` 처럼 코드로 장면을 만든다. 단, **스크립트는 Blockshot Spec을 만드는 빌더** 일 뿐이며 렌더러·DOM·네트워크에는 접근할 수 없다. 실행 결과는 항상 직렬화 가능한 Spec이고, 그 Spec이 뷰포트에 반영된다.

- 구현(빌더): [`packages/core/src/builder.ts`](../packages/core/src/builder.ts)
- 런타임: 브라우저 Web Worker 샌드박스 (M3), 이후 QuickJS-wasm

## 1. 실행 모델

```
스크립트 문자열 ──▶ Worker(샌드박스) ──▶ scene 빌더 호출 ──▶ Spec(JSON) ──▶ 검증 ──▶ 스토어 반영
```

- 전역으로 `scene`, `vec`, `rand` 만 노출
- 기본 모드는 **새 장면 생성**. `// @blockshot mode=merge` 주석이 첫 줄에 있으면 현재 장면에 병합(같은 id는 덮어씀)
- 제한: 실행 2초, 객체 2,000개, `fetch`/`importScripts`/DOM 없음

## 2. 빠른 예시

```js
scene.settings({ fps: 24, duration: 8, resolution: [1920, 1080] });
scene.room({ width: 6, depth: 8, height: 2.8, doors: [{ wall: "north", offset: 1.5 }] });

const sofa = scene.prop("sofa", { at: [0, 0, -2.6] });
scene.prop("table", { at: [0, 0, -1.4] });

const minsu = scene.actor("minsu", { name: "민수", color: "#e4572e", at: [1.5, 0, -3.6], facing: 0 });
const jiyoung = scene.actor("jiyoung", { name: "지영", color: "#4c9f70", at: [-0.6, 0, -2.4], pose: "sit" });

minsu.walkTo([0.6, 0, -1.8], { start: 0.5, duration: 3 });
minsu.lookAt(jiyoung.head, { start: 3.5 });
jiyoung.lookAt(minsu.head, { start: 1.0 });

const camA = scene.camera("camA", { lens: 35, at: [2.8, 1.6, 2.5], lookAt: minsu.head });
camA.moveTo([1.8, 1.5, 1.2], { start: 0, duration: 4, ease: "easeInOut" });

const camB = scene.camera("camB", { lens: 85, at: [-1.6, 1.25, -0.6], lookAt: minsu.head });

scene.shot("S1", { camera: camA, start: 0, end: 4, prompt: "a man walks into a warm living room" });
scene.shot("S2", { camera: camB, start: 4, end: 8, prompt: "over-the-shoulder close-up, he smiles" });
```

## 3. API 레퍼런스

### scene

| 메서드 | 설명 |
| --- | --- |
| `scene.settings({ fps?, duration?, resolution?, seed? })` | 렌더 설정 |
| `scene.meta({ title?, description? })` | 메타데이터 |
| `scene.ground({ size? })` | 바닥만 있는 공간 |
| `scene.room({ width, depth, height?, doors?, windows? })` | 방. `doors: [{ wall, offset?, width? }]` |
| `scene.street({ length?, roadWidth?, sidewalkWidth? })` | 거리 |
| `scene.actor(id, opts) → Actor` | `opts: { name?, color?, height?, build?, at?, facing?, pose?, aim? }` — `aim`: 타겟 포인트(얼굴 기준 오프셋, 기본 얼굴) |
| `scene.prop(type, opts) → Prop` | `opts: { id?, at?, facing?, rotation?, scale?, color?, aim? }` — `aim`: 타겟 포인트(기본 상자 중앙) |
| `scene.use(id) → handle` | **이미 있는 오브젝트**(손으로 배치했거나 이전 스크립트가 만든 것)의 핸들. 인물이면 Actor, 카메라면 Camera 핸들(걷기·줌 등 모든 메서드 사용), 소품이면 `{ id }`. 없는 id면 가능한 id 목록과 함께 오류 |
| `scene.patchAction(id, fields)` | 기존 액션 한 개의 필드만 수정 (id = 명시한 `id` 또는 `act1`, `act2` … 위치 라벨, `type`은 변경 불가) |
| `scene.removeAction(id)` | 기존 액션 한 개 삭제 |
| `scene.clearActions(idOrHandle?)` | 기존 액션 삭제 — 한 오브젝트 것만, 인자가 없으면 전부 (처음부터 다시 연출할 때) |
| `scene.patchShot(id, { camera?, start?, end?, name?, description?, prompt?, crossAxis? })` | **기존 샷의 일부만 수정** — 적은 필드만 바뀐다. 없는 id면 가능한 id 목록과 함께 오류 |
| `scene.removeShot(id)` | 샷 하나 삭제 |
| `scene.shot(id, { ..., after? })` | 샷 추가. `after: "S1"`이면 S1 바로 뒤에 삽입, 없으면 끝에 추가 |
| `scene.clearShots()` | 샷 목록 전체 비우기 (컷을 처음부터 새로 만들 때만) |
| `scene.reflect(ref, mirror) → ref` | `scene.reflect(man.chest, mirror)` → `"man.chest@mirror"`: 거울 속 반사 위치. 카메라 `lookAt`/`orbit`의 대상으로 쓴다 |
| `scene.camera(id, opts) → Camera` | `opts: { name?, lens?, sensor?, at?, lookAt?, rotation? }` |
| `scene.shot(id, { camera, start, end, description?, prompt?, crossAxis? })` | 샷 추가. `crossAxis: true` = 이 샷으로 들어오는 컷이 180° 축선을 일부러 넘음(축선 경고 끄기) |
| `scene.key(targetId, property, t, value, ease?)` | 저수준 키프레임 (`property`에 `bone:rightUpperArm`·`hips` 가능) |
| `actor.bone(bone, [[t,[x°,y°,z°]],…], ease?)` | 뼈 키프레임 (오일러 도). `actor.hips([[t,[x,y,z]],…])`는 골반 |
| `scene.toSpec()` | 현재까지의 Spec 반환(런타임이 자동 호출) |

### Actor

| 메서드 | 설명 |
| --- | --- |
| `.walkTo(to, { start, duration?, speed?, direct? })` | `to`는 Vec3 또는 경유점 배열. 막힌 구간은 엔진이 알아서 우회(`direct: true`면 직선). 발은 엔진이 디딤마다 고정한다 — 경유점이 여럿이면 짧은 walkTo를 여러 번 잇기보다 한 번에 넘기는 편이 자연스럽다 |
| `.runTo(to, { start, duration?, speed? })` | |
| `.contact(part, on, { start, end?, blend? })` | 손(`hand_l`/`hand_r`)을 몸 표면(`thigh_l`·`knee_r` …)·점·소품·참조에, 발(`foot_l`/`foot_r`)을 `"floor"`에 붙여 둔다 — 상체를 기울여도, 팔을 키로 잡아도 손이 그 자리에 남는다 |
| `.turnTo(target, { start, duration? })` | target: Vec3 · 참조 · yaw(도). 서 있으면 발을 옮겨 디디며 돈다 |
| `.pose(name, { start })` | `stand` `walk` `run` `sit` `point` `talk` `idle` — 이전 포즈에서 0.35초 블렌드 |
| `.sitDown(seat, { start, duration?, offset? })` | 의자/소파(핸들·id)로 걸어가 앉기. 소파는 `offset`(m)으로 자리를 나눈다 |
| `.standUp({ start, duration?, to? })` | 일어나 좌석 앞으로 걸어 나옴(좌석 깊이에 맞춰, 또는 `to`) |
| `.gesture(name, { start, duration?, side?, intensity? })` | 몸짓(wave·nod·shrug·clap·cheer …). 포즈 위에 얹힌다 |
| `.handShape(shape, { start, hand?, duration? })` | 손가락 모양: open·relaxed·fist·point·pinch·grip (`hand`: left/right/both) |
| `scene.use(propId).drive(path, { start, speed?, duration?, ease?, radius?, drift? })` | 차량/소품이 경로를 따라 이동(모서리는 곡선, 차체 기울기·바퀴 회전 포함). `drift`(0–1)는 코너에서 꼬리를 빼는 드리프트. 소품은 `drive` 없이는 움직이지 않는다 |
| `scene.use(propId).follow(leader, { start, gap?, offset?, duration? })` | `leader`의 경로를 `gap` m 뒤에서 쫓아간다 (자동차 추격) |
| `camera.mount(target, { start, offset?, look?, end? })` | 카메라가 인물/차에 실려 따라간다 (추격 카메라) |
| `.hold(prop, { start, at?, hand?, duration?, then? })` | 소품을 집어 들고 사용: `at` "ear"(통화)·"mouth"·"chest"·"carry", `then` "keep"/"put". 부위 참조에 `ear_l`/`ear_r`/`mouth` 추가 |
| `.reach(target, { start, hand?, duration? })` | 손을 대상으로 뻗기(IK). `target`은 점/소품/`인물.hand_r` 등 |
| `.lookAt(target, { start, end? })` | 머리 방향 |
| `.head` `.chest` `.pelvis` `.handL` `.handR` | 참조 문자열 (`"minsu.head"`) |
| `.id` | |

### Camera

| 메서드 | 설명 |
| --- | --- |
| `.moveTo(pos, { start, duration, ease? })` | 이동 |
| `.dolly(distance, { start, duration, ease? })` | 바라보는 방향으로 전진(+)/후진(-) |
| `.truck(distance, opts)` / `.pedestal(distance, opts)` | 좌우 / 상하 평행 이동 |
| `.orbit(around, { degrees, start, duration, ease? })` | 원호 이동 |
| `.zoom(focalLength, { start, duration, ease? })` | 초점거리 변경 |
| `.lookAt(target, { start, transition?, duration?, ease? })` | 피사체 키 — `start`부터 이 피사체(`null`이면 자유 — 회전값으로). `transition`: `"cut"`(즉시) · `"pan"`(`duration`초 회전) · `"whip"`(휩 팬), `ease`로 움직임 형태 |
| `.id` | |

### 유틸

| 이름 | 설명 |
| --- | --- |
| `vec.add(a, b)`, `vec.sub`, `vec.scale(a, s)`, `vec.lerp(a, b, t)` | Vec3 연산 |
| `vec.polar(radius, deg, y?)` | 원 위의 점 (`[r·sin, y, r·cos]`) |
| `rand()` | `settings.seed` 기반 결정론적 난수 [0,1) |

## 4. 반복/절차적 예시

```js
// 원형으로 배치된 군중 + 카메라 360도 오빗
scene.ground({ size: 30 });
for (let i = 0; i < 16; i++) {
  const p = vec.polar(5, (360 / 16) * i);
  scene.actor(`crowd${i}`, { at: p, facing: (360 / 16) * i + 180, color: "#8892a0" });
}
const hero = scene.actor("hero", { at: [0, 0, 0], color: "#f2c14e", pose: "point" });
const cam = scene.camera("cam", { lens: 24, at: [0, 2.2, 9], lookAt: hero.chest });
cam.orbit([0, 0, 0], { degrees: 360, start: 0, duration: 10 });
scene.settings({ duration: 10 });
scene.shot("S1", { camera: cam, start: 0, end: 10 });
```

## 5. 오류 처리

- 스크립트 예외: 줄 번호 + 메시지를 표시하고 자가 교정 프롬프트 생성
- 빌더 단계 오류(존재하지 않는 id 참조 등)는 예외로 즉시 던짐
- 최종 Spec은 다시 zod로 검증 (스크립트와 JSON 경로가 같은 검증을 통과)


## 이미 배치된 장면에 애니메이션 추가 (`base`)

`runScript(source, base?)`에 현재 장면(`base`)을 주면 스크립트가 **그 장면 위에서** 실행된다: 배치(인물·소품·공간·모델)는 그대로 유지되고, `scene.use(id)`로 잡은 핸들로 움직임·카메라·샷만 추가한다. 결과 `toSpec()`은 base + 추가분이다. 앱의 **AI/스크립트 → 🎬 배치한 장면에 애니메이션** 모드가 이 방식을 쓴다.

```js
const a = scene.use("actor1");
a.walkTo([[-2.5, 0, 0.6], [-1.6, 0, -1.2]], { start: 0.5 });   // 테이블을 돌아서
a.pose("sit", { start: 6 });
scene.use("cam1").zoom(85, { start: 1, duration: 5 });
scene.settings({ duration: 8 });
```


## 코드 API (Remotion 스타일 — 시간의 함수로 연기 쓰기)

반복·리듬·절차적 연기는 키를 일일이 쓰지 않고 시간 함수로 쓴다. 모두 결정론적(난수는 `noise`/`rand`)이다.

| 전역 / 메서드 | 설명 |
|---|---|
| `interpolate(x, [x0,x1,…], [v0,v1,…], { ease?, extrapolate? })` | 구간 선형 매핑(기본 범위 밖은 고정). 출력은 숫자 또는 `[x,y,z]` |
| `spring(t, { stiffness?, damping?, mass? })` | 0→1 감쇠 스프링(오버슈트 후 안정) |
| `noise(x, seed?)` | 부드러운 결정론적 노이즈 −1…1 (핸드헬드 흔들림·숨쉬기) |
| `clamp(x, lo, hi)` | |
| `scene.sequence(start, () => {…})` | 안에서 만든 액션 `start`/`end`와 키 시간이 `start`초만큼 밀린다(중첩 가능, `scene.shot`은 절대 시간) |
| `scene.animate(id, property, { from?, to, fps?, tolerance?, ease? }, t => value)` | `t`(from 기준 초)의 함수를 샘플링해 단순화된 키로 기록. property: `position`·`rotation`·`focalLength`·`hips`·`bone:<이름>` |
| `actor.boneFn(bone, { from?, to }, t => [x°,y°,z°])` | 뼈 하나를 시간 함수로 |

```js
const a = scene.actor("minsu", { at: [0, 0, 0] });
scene.sequence(1, () => {
  a.boneFn("head", { to: 4 }, (t) => [0, 25 * Math.sin(t * 2 * Math.PI * 0.5), 0]);      // 천천히 고개 젓기
  a.boneFn("chest", { to: 4 }, (t) => [2 * noise(t * 1.5, 3), 0, 0]);                       // 숨쉬기
});
scene.animate("camA", "position", { from: 0, to: 3 }, (t) => [interpolate(t, [0, 3], [0, 2], { ease: "easeInOut" }), 1.6, 5 - 2 * spring(t)]);
```
