SCENEGOD/scenegod/web/grammar.js
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual
quality, discoverability):
- ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun
  intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and
  a warm key rendered neutral. Single biggest look win in the app.
- Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates
  that no longer double as the shadow catcher (dedicated ShadowMaterial).
- A Render button in the scene bar. finalRender had ZERO callers - the app's
  deliverable was console-only.
- Save actually saves the stage you built (gizmo moves, inspector edits), and
  Save/Load stop lying about what happened.
- 2x supersample + lanczos downscale; correct bt709 tagging and faststart.
- Frame guide, camera-from-view, dock counts, delete confirmations.

DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first):
- BLOCKER: save wrote the playhead pose over the authored rest transform of
  keyed entities, so every save produced a different file.
- renders were converted with the bt601 matrix while tagged bt709.
- deleting an unkeyed camera stranded its cut -> scene 422s forever.
- corner plates showed one photo at two exposures across the fold.
- exposure was advertised as keyable but nothing keyed it, so a second lighting
  preset permanently poisoned the first.
- the audio pre-flight could never fire (FastAPI does not route HEAD -> 405).
- the two render buttons were not mutually exclusive.
- frame guide letterboxed a narrow viewport that the render does not crop.

ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and
skipped by cutCameraAt, rather than trying to keep every undo history clean -
deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry
can resurrect a cut for a dead camera. Verified against the real validator.
Also replaced a VACUOUS test that drained a stack whose only entry was a seeded
no-op; timeline_test.mjs now replays both real histories and discriminates.

Verified: 4 JS suites + 23 server groups + render client green; blocker repro
now preserves the authored rest at every playhead position; save round-trips
200 with zero dangling cuts; zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:41:17 +10:00

95 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// grammar.js — classic cinema vocabulary as PURE DATA (Lane A owns; every lane reads).
// No imports, no DOM, no three — safe under plain node. Presets/LLM ops both resolve here,
// so the button path and the /director path can never drift apart.
// Shot sizes: vertical span the framing should cover, as a fraction of subject height,
// @ where the frame centers on the body (fraction of subject height from the feet).
export const SHOTS = {
ECU: { span: 0.12, center: 0.93, label: 'extreme close-up' },
CU: { span: 0.25, center: 0.87, label: 'close-up' },
MCU: { span: 0.45, center: 0.75, label: 'medium close-up' },
MS: { span: 0.65, center: 0.65, label: 'medium shot' },
MWS: { span: 0.85, center: 0.55, label: 'medium wide' },
WS: { span: 1.2, center: 0.5, label: 'wide shot' },
EWS: { span: 2.5, center: 0.5, label: 'extreme wide' },
};
// Camera attitude relative to the aim point. elev = degrees above (+) / below () the
// aim; roll = dutch tilt in degrees; ots = over-the-shoulder of the OTHER subject.
export const ANGLES = {
eye: { elev: 0, roll: 0 },
low: { elev: -18, roll: 0 },
high: { elev: 22, roll: 0 },
dutch: { elev: 0, roll: 8 },
ots_L: { elev: 0, roll: 0, ots: 'L' },
ots_R: { elev: 0, roll: 0, ots: 'R' },
};
// Full-frame focal lengths (mm). Vertical fov = 2·atan(12/f) — 24mm-tall sensor.
export const FOCALS = [18, 24, 35, 50, 85];
// Lighting looks: key ("sun") color/intensity + boom direction (degrees), hemisphere
// ambient, a background clear color, and `exposure` — the ACES tone-mapping level for the
// whole frame. Without it the presets sit ~6 stops apart in key with nothing rebalancing
// overall level: night/noir/horror read as black holes and day clips. It rides the ambient
// entity as an ordinary param (applyLight writes it like `bg`) and applyOps emits it as its
// own generic `light` op so the timeline KEYS it — the renderer's exposure is global, so an
// unkeyed one would leave the whole earlier stretch of a scene rendering at the later
// preset's level. Values are what applyLight writes verbatim.
export const LIGHTS = {
day: { sun: { color: '#fff6e4', intensity: 3.0, elev: 55, azim: 35 },
ambient: { color: '#dfe9ff', intensity: 1.15 }, bg: '#8fb0d6', exposure: 1.0 },
golden_hour: { sun: { color: '#ffb469', intensity: 2.6, elev: 9, azim: -55 },
ambient: { color: '#ffd9b0', intensity: 0.55 }, bg: '#d98a52', exposure: 1.1 },
night: { sun: { color: '#7e93d6', intensity: 0.6, elev: 40, azim: 150 },
ambient: { color: '#31405e', intensity: 0.4 }, bg: '#0a0e1a', exposure: 1.6 },
noir: { sun: { color: '#ffffff', intensity: 3.4, elev: 52, azim: 75 },
ambient: { color: '#1c1c24', intensity: 0.15 }, bg: '#08080a', exposure: 1.2 },
horror: { sun: { color: '#b9d6a4', intensity: 1.7, elev: -30, azim: 10 }, // underlight
ambient: { color: '#25302b', intensity: 0.25 }, bg: '#05070a', exposure: 1.4 },
neon: { sun: { color: '#ff4fd8', intensity: 1.9, elev: 25, azim: 120 },
ambient: { color: '#2de2e6', intensity: 0.9 }, bg: '#150420', exposure: 1.3 },
overcast: { sun: { color: '#dfe4ea', intensity: 1.2, elev: 70, azim: 0 },
ambient: { color: '#c7cdd6', intensity: 1.4 }, bg: '#9aa3ad', exposure: 1.0 },
};
// Camera MOVES (M11): each one solves to a START and an END camera state off the same subject.
// `kind` selects the solver in presets.js. Amounts are FRAME-RELATIVE (multiples of the framed
// half-height / half-width, which is span·H/2 by definition) so a move reads the same on a
// close-up and a wide, on an 18mm and an 85mm — never a magic metre count.
// dolly push/pull along the camera's own view axis; distance of one shot size (rot+fov fixed)
// truck lateral, camera stays PARALLEL (rot fixed); amount = fraction of the half-frame WIDTH
// crane vertical, re-aims at the subject; amount = fraction of the half-frame HEIGHT
// orbit arc around the subject at constant distance; degrees of sweep
// zoom focal length ONLY — pos+rot identical, fov changes (a zoom is not a dolly)
// dir: 1 = camera-left / down, +1 = camera-right / up. step: rungs along SHOT_ORDER / FOCALS.
export const MOVES = {
push_in: { kind:'dolly', step: 1, label:'push in' },
pull_out: { kind:'dolly', step: -1, label:'pull out' },
truck_L: { kind:'truck', dir: -1, amount: 0.55, label:'truck left' },
truck_R: { kind:'truck', dir: 1, amount: 0.55, label:'truck right' },
crane_up: { kind:'crane', dir: 1, amount: 1.2, label:'crane up' },
crane_down: { kind:'crane', dir: -1, amount: 1.2, label:'crane down' },
orbit_L: { kind:'orbit', dir: -1, degrees: 25, label:'orbit left' },
orbit_R: { kind:'orbit', dir: 1, degrees: 25, label:'orbit right' },
zoom_in: { kind:'zoom', step: 1, label:'zoom in' },
zoom_out: { kind:'zoom', step: -1, label:'zoom out' },
};
// Shot ladder, wide → tight. push_in/pull_out step one rung along it; ends clamp (see DOLLY_CLAMP).
export const SHOT_ORDER = ['EWS', 'WS', 'MWS', 'MS', 'MCU', 'CU', 'ECU'];
// Move timing: `inout` for anything with a human on the wheel, `linear` for mechanical/motion-control.
export const EASES = ['inout', 'linear'];
export const MOVE_MAX_DUR = 6; // seconds — default duration is the scene's remainder, capped here
export const DOLLY_CLAMP = 1.4; // already at ECU/EWS → dolly this factor closer (÷) / farther (×)
// Blocking marks, stage-relative. pos marks move (keep current y); face marks rotate.
export const MARKS = {
center: { pos: [0, 0, 0] },
two_shot_L: { pos: [-0.7, 0, 0] },
two_shot_R: { pos: [0.7, 0, 0] },
face_camera: { face: 'camera' },
face_other: { face: 'other' },
};