Compare commits

...
49 Commits
Author SHA1 Message Date
Dejvino 23d3696adc metabolism: region pool exactness + browser boot — deterministic velocity pass, pool warm-gate for the web worker boot, breaker
Two root causes fixed end to end:

1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks
   and then read halo blocks no neighbour had committed yet — pool runs
   diverged at random frames (2/4 runs). The velocity pass now runs as a
   block-owner section at the END of phaseStrains; phaseFields only reads
   committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9
   (including 1600-frame x 2-worker runs across the section boundary).

2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a
   window) froze pending web-worker script loads — Chromium routes worker
   startup through the busy main thread, so the show stalled 30s and
   threw 'region pool stalled'. The web shell now acks ('pool') after
   makeRegion (mail[63] is a real boot counter), the world runs cold until
   every worker acks (step/stepTo skip frames; post-warm catch-up is
   unbounded), and loadSongData awaits player.warm() before the show.
   Node pools are untouched (native threads answer barriers directly).

Also: Player.build/rewind dispose the old pool (no more leaked idle
worlds); region-core.js is the shared worker body; region-worker.web.js
the browser shell (vite bundles it via the literal new Worker(new URL)
spelling — dist emits a self-contained worker asset instead of a broken
base64 data URL).

Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip;
>=30 frames behind the audio clock for >=750ms trips (dormant while
stepTo catches up unconditionally — the fps path is live). 12/12 mirror
scenarios pass.

Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser:
steady 30fps including a 64s soak to 20k organisms at 640x360.
2026-10-30 07:26:28 +01:00
Dejvino 53a973c34e metabolism: DESIGN-REVISION.md — the wells & LittleVM-castes redesign presented as the reviewable proposal of record 2026-10-28 16:53:01 +01:00
Dejvino cfc0bddb1f Metabolism: browser fix — the arena stays a plain ArrayBuffer unless a region pool will share it; vite serves cross-origin-isolation headers so the browser can opt into SAB 2026-10-28 15:33:07 +01:00
Dejvino d6e97a2b52 Metabolism: region pool — SharedArrayBuffer arena, band-parallel phases (block sums / VM decisions / advection+reaction), bit-identical to serial; 5.9x at 41k organisms; provider protocol sketched 2026-10-28 15:25:52 +01:00
Dejvino 3a72525ccb Metabolism: decision-cadence (think slower than you move) + empty-block skip; flat typed-array bytecode; WebGPU decision-layer slice (experimental) + heavy-world benchmark 2026-10-28 13:51:58 +01:00
Dejvino 476fa1833a Metabolism M2.9: flora islands, gardens, walls, farmer strain, aimed work (VM AIM actions), food-limited ecology rebalance 2026-10-28 12:49:17 +01:00
Dejvino 1d497e556e Metabolism M2.8: rock — inert resource the song drops and builders quarry, carry and build with; livelier metabolisms 2026-10-28 11:36:30 +01:00
Dejvino 8abefb16b7 Metabolism M2.7: one species, many minds — strains (song-compiled LittleVMs), bodies with fixed allegiance, living wells, food-limited economy 2026-10-28 11:08:39 +01:00
Dejvino f939351689 Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells) 2026-10-28 10:07:05 +01:00
Dejvino 16f4b4a805 Metabolism: add README
Project pitch, invariants, quick-start commands, layout, reference
songs, status, and how to read the coupling × residual report.
2026-10-24 08:27:56 +02:00
Dejvino 13b0939221 Metabolism: song as substrate, ecology grown from the track
A new top-level section building the metabolism concept: the track
supplies the food field, event impulses and per-section laws; the seed
picks the species (a rule set, not a parameter vector). Same song + seed
=> bit-identical frames; different songs => different worlds.

- analysis: single FFT pass -> per-frame feature table; score (onsets
  folded into world geometry via voice ring + pitch-class sector), wells
  (food placed by the harmony), graph (band correlations -> interaction
  matrix), laws (per-section regimes)
- sim: typed-array world, 3 castes (builder/orbit, forager/commute,
  spark/orbit-wide) with causality rings, persistence trails,
  consequence field; fully deterministic
- lens: software framebuffer renderer (PNG in node, ImageData in
  browser) + event-reactive camera (cuts on activity change, alternating
  shot sizes, drift-and-push)
- metrics: coupling x residual fitness (not per-frame descriptors) plus
  a grown-structure fingerprint; report + strip tools; 15 tests green
- DESIGN.md captures the concept, invariants and build order

3 reference test signals (drone/runner/dusk) grow measurably distinct
worlds while seeds of one song stay in the same biome.
2026-10-24 08:23:33 +02:00
Dejvino 6a2af2e45f Dungeon themes: per-song visual identity (crypt/overgrown/ember/flooded/windswept)
One album must not render the same dungeon twice. dungeonTheme() scores
the measured summary per theme — pace terms gate on tempoConfidence so
doubled detectors never read fast, crypt is penalised for blazing
masters — plus small seeded jitter so ambiguous songs vary across seeds
while decided races stay decided. The plan carries the theme name;
dressing mix, planting/timber/puddle densities follow it, and the facade
builds all materials (stone/floor multipliers, torch + light color, wood,
fog tint) from the same record.

Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners
stable across seeds; plan reshaping, facade wiring, and murk-vs-clear
gates covered by tests.
2026-10-08 22:20:04 +02:00
Dejvino c96e810d44 Dungeon venue: grid-first traversable dungeon with cast, loot and encounters
New VenueGenerator 'dungeon' archetype. The floor plan is generated first
on a 44x44 grid (rejection-sampled rooms, Kruskal MST + loop edges,
L-shaped corridors, BFS-verified connectivity); walls are merged runs
built exactly where open meets solid, so the shell has no holes, and
the walk route is a closed travelling tour through every room.

The camera either walks first-person (hero hidden to avoid clipping) or
plants on a per-room watch cell with line-of-sight to the route and
tracks the actor. Enemies (walker/structure/swarm ActorSpecs from the
track's own summary) lurk, stand sentry, dart and drift; loot caches
pause the analytic odometer, which can hold or advance but never retreat.

Includes dungeon-debug.html (2D map + timeline stills), 14 map tests
plus facade camera tests, and the portcullis-holds-for-the-whole-party
regression test.
2026-10-04 22:13:25 +02:00
Dejvino 3df4b3545b Deep-link view settings survive file drops
Dropped files ignored pins while the URL still claimed them, so the census venue replaced the demo's pinned room. Drop loads now inherit venueSel/viz from the query, and stale bank addressing (song/seed/frame/warm) is cleared on drop while view settings stay.
2026-09-21 21:14:02 +02:00
Dejvino 56919ed819 Fix dropped-file load crash on null pins
applyPins destructured its argument, whose default only covers undefined; dropped files pass pins: null. Guard with || {}.
2026-09-21 21:01:47 +02:00
Dejvino 9fc80c38eb Ride: roadside figures always visible, statue-scale, glowing
Forced actor slots on like the billboard (sparse setups hid them), figures normalized to landmark scale against houses, crew-like emissive so they read at night. They were present but effectively invisible: gated, unlit, person-scale at the frame edge.
2026-09-21 20:47:53 +02:00
Dejvino 18f1295984 Ride: roadside actor figures among the props on some songs
Two seeded slots on ~40% of builds hold figures of the track's own cast, grown on first update like the crew (identity arrives via update), normalized to person-scale with feet planted via measured bounds, palette-recolored per frame, glancing around analytically. Static for the whole video: they stream with their slots and never pop. Verified variance across seeds, pose and planting.
2026-09-21 20:17:59 +02:00
Dejvino 215fe98c06 Ride: front mask with grille plus rear fascia, patch the holes
Full-width nose fascia with inset grille mouth, chrome slats and bezel-ringed lamps seated into it; rear fascia carrying the taillight bar plus diffuser fins. Patch-up: front side panels, rear quarters, floor pan and taller skirts sealing every daylight gap; lamps/grille/mirrors attached to body faces, all volumes interpenetrating. Verified front/rear/side, lint + tests green.
2026-09-21 19:58:15 +02:00
Dejvino cf9b4c111a Ride reflections: quarter-turn env mapping + body patch panels
Mirrored env azimuth rotated 90 degrees in both sky and paint mappings (shared constant): fold lines sit fore/aft instead of mirroring the world at side views. Same render also completes the body patch-up: front side panels, rear quarters, floor pan, taller skirts sealing the doors, lamps/grille seated into the bumper, mirrors rooted in the wall. Verified on the 10x3 grid: reflections track the viz per cell, no seam, no holes at card scale.
2026-09-21 19:09:18 +02:00
Dejvino 3a3022327c Ride sky plays the viz so the paint mirrors something true
Shared sky dome gains an optional viz overlay (off by default; chamber/stage untouched): the running video mixed over the gradient, masked to the upper sky so the horizon stays fog-matched. Same mirrored-sphere projection as the bodywork reflection, so paint and sky agree. Ride binds its vizTarget and holds 0.55.
2026-09-21 19:03:54 +02:00
Dejvino ac6654b0dd Ride: proper 2/4 seating plus doors, dash, console, wheel
Two rows of two like a real car (2 riders: 2-seater; more: 4-seater with one seat left empty); riders sit on cushions against backrests with headrests, driver ahead of the wheel. Long painted doors with handles, full-width dashboard with dial strip, center console with shifter glow, torus steering wheel on a column. Fender arches fattened. Rng cadence preserved per rider so downstream draws don't move.
2026-09-21 18:55:09 +02:00
Dejvino 89f295532a Ride: full sportscar trim so it reads as a convertible, not a tractor
Hood, trunk, bumpers, side skirts, torus-arch fenders, grille, headlamps, taillight bar, mirrors, rear spoiler, exhausts. Fixed layout in palette paint (painted panels join the viz reflection feed); dark trim stays matte. Verified front/side/rear: lamps on, viz pattern on the hood, arches over wheels.
2026-09-21 18:27:45 +02:00
Dejvino 2704831047 Ride: mirror the viz on the bodywork instead of a glass band
The wraparound band read as bent-glass weirdness. Replaced with environmental spherical projection: chassis and cabin materials sample the shared viz texture by reflected view direction (onBeforeCompile injection, since this three version compiles 2D envMaps as cube samplers), added to emissive so palette bodywork color is retained. Verified on the 10x3 grid: patterning tracks the viz per cell while base colors hold.
2026-09-20 20:45:03 +02:00
Dejvino a0665d75df Ride: viz on a wraparound roof band that reads from every angle
The dash radio and lucky billboards hid the viz, so it gets a 200-degree glass band with the same feed, floated above the body's measured highest point (procedural bodies vary per track; fixed glass gets buried, measured) on cabin masts. Verified: 504px at strong contrast on a rear-side card, present by construction on all orbit angles.
2026-09-20 20:02:12 +02:00
Dejvino 8870f1edc9 Ride: play the viz on the windshield
The dash radio (0.6x0.35) and lucky billboards were the only viz surfaces, so pinned viz never read on Ride cards. A raked windshield plane across the cabin front carries the same viz texture feed at 4x the area, front-hemisphere only like real glass, with A-pillars seating it. Verified front/rear/side in the orientation rig and viz-bound in full composite.
2026-09-20 19:14:36 +02:00
Dejvino 4d05250294 Clickable previews: exact app deep-links everywhere
Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
2026-09-20 18:42:53 +02:00
Dejvino cc8888077f Fix venue-gallery viz pins that show no viz
Ride + corridor showed no corridor: with a room present the viz plays on its screens (radio/window/wall), so a dark model viz on the Ride's dash radio is invisible, and the shared rig was filming it through the room camera grammar meant for another world (directCamera rooms now keep the subject grammar + viz framing). Battery pins switched to combos that visibly read (Chamber + Kaleido, Stage + Assembly); scenario text states the visibility rule so a dead pin can't hide again.
2026-09-20 17:28:34 +02:00
Dejvino 6c7e314a3e Clickable previews: every still links to its exact app frame
New shared component src/ui/preview.js (appFrameUrl + previewLink): song + seed + frame + venue/venueSel deep-links. App honors ?frame= (warm seek on bank-song load) and ?venueSel= via shared pinVenueShell helper (src/venues/pin.js), which filmstrip and venue-gallery now also use. Filmstrip figures and venue-gallery cards wrap canvases; gallery cells stay unlinked (isolated scene probes, not reproducible in-app).
2026-09-20 17:08:03 +02:00
Dejvino cd7dfcd63d Vis testing battery: library + debug page sections
Collects every manual testing entry point into one library (src/debug/battery.js) rendered from the live registries: all 17 bank songs with app/+venue/strip links, all 70 scenes grouped by family with gate/wide links, all 3 venues, 9 curated param and scenario combos, and the graduated lab pages with their headless runners. debug.html renders it; new songs/scenes/venues join free.
2026-09-20 16:54:39 +02:00
Dejvino a99a7cbe49 Continuity rule: discrete params hold per cue; add DESIGN.md + AGENTS.md
Actors and objects change only at camera cuts. int/bool/discrete
params skip drift, lookahead and reactivity; discrete slowAxis steps
on the section-held journey. Venues fade instead of toggling on
interpolated popMul; roadside density is arrangement-only.

DESIGN.md collects the project's design decisions; AGENTS.md points
agents at it and the build-visualizer loop.
2026-09-20 16:42:44 +02:00
Dejvino 6e70f95d4d Chamber: bring back the actors as wandering figures among the junk
Headcount split (1-3 actors by population, rest junk): actors take the
first indices so popMul gating never hides the cast while thinning junk.
Each figure is the track's monolith (shared part geometries, own palette
material, distinct entries per figure, feet planted via bbox) and walks
a closed analytic loop - stand at furniture A facing it, meet at the
centre facing its partner (a lone figure watches the window instead),
stand at furniture B facing it - on a slow seeded unsynced period.
Smootherstep legs rest at both ends, so figures pause, look, then move;
facing blends toward the next leg's target for smooth turns. Positions
first for all figures, then yaws off same-frame positions; nothing
integrates, so seeks match playback. Figures breathe brighter than junk
to read against their own room in monochrome palettes.

Verified: 24/24 tests, scene lint clean, re-render bit-identical,
chamber strips centre 0.09/0.12 (pre-existing flat), drone 0.26->0.57,
elegy 0.61->0.59, agit unchanged.
2026-09-19 19:48:18 +02:00
Dejvino 3b65ea905c Chamber: procedural junk furniture instead of actor-part memories
Tables, chairs, cupboards, shelves with seeded book rows, potted plants
and box stacks built from primitives, dealt from a shuffled deck per
(seed, arrangement) so the first slots always land distinct types. Same
spiral + repulsion placement with per-item footprint radii; front clamp
pulled to z<=-1.5 for furniture scale so close cameras never wear a
table as a lens bar. Static (junk doesn't bob) with the existing
story-driven emissive breathe; re-hang on arrangement jumps moves
everything under the camera cut. popKey drops the actor dependence.

Verified: 24/24 tests, scene lint clean, same-frame re-render
bit-identical, rooms read as furnished across centre/drone/elegy.
Chamber-forced strips: elegy direction 0.26->0.61; centre stays
pre-existing-flat (0.11->0.09, dark song, same early arrangement);
drone agit unchanged (viz-driven).
2026-09-19 19:08:50 +02:00
Dejvino fd0cbe0553 Ride: plant the planted shots (passerby/billboard camera + planner fixes)
driveRideCamera checked the passerby/billboard plants after the generic
chase branch, which returns on every planned shot — both plants were dead
code (camera orbited, only the car drive-through worked). Check them first.

Planner fixes for the now-live plants:
- a planted section keeps the plant on its first cue only; later cues fall
  back to the screen/room rotation (a whole section planted is a stuck
  camera with the car looping through one frame)
- venueShotAt holds plant position too, not just the look
- no consecutive planted sections in either order; the section-level
  history never forms streaks for single-cue sections, so gate on the
  previous section directly (draw-first, rng-neutral elsewhere)
- passerby plant back/up, aimed between approach and pass point so the
  whoosh does not swallow the frame

Verified: billboard holds centre-frame with the viz live while the car
crosses mid-cue; passerby plant static +/- handheld; tests 24/24, scene
lint clean, standard gates unchanged vs HEAD (P9/P12 + census
centre/drone fail pre-existing).
2026-09-19 09:08:11 +02:00
Dejvino ce79ccf387 Sharp transitions cut color instantly and always reframe
Hard-cut cues now obey a single rule: the colour jumps with the image
(palette blend lerp skipped, even under blend-mode directors — the 2-frame
image ramp stays for flash safety) and both cameras must land somewhere
new, never hold static.

- Viz gaze relocates unconditionally on hard cuts (was +0.3 chance)
- Venue marks force fresh size (≠ previous) and flip look target
  screen↔room on hard-cut cues, landed instantly via venueShotAt
- Motive yields to grammar: a cutaway needn't face the action

Verified on lattice (2 hard cuts): gaze jumps, venue flips size+target
with 1.4/2.6-unit moves. Phases 8/11 fast gates 30/30.
2026-09-19 08:16:46 +02:00
Dejvino 822f4b12ef Ride: venue-owned camera (directCamera) + sidewalk passerby shot
Camera architecture: a module with directCamera:true drives its own rig
inside update() (analytic f(timeline, seed, framing, venueState)); the
shared rig is ignored for that layer only. ModelLayer prefers own camera,
Show skips shared injection. Ride migrates its full grammar over
(traverse, orbit, car tracking, jumps, clamp); Compositor ride branches
removed. Chamber/stage unchanged on the shared path.

Sidewalk shot: new ride-only 'passerby' plan target (breaths only, never
climax, never twice running) plants a static roadside camera; the module
drives the car +12 to -14 on driveT with the world frozen. Verified
far-pass-away through the Show path; lint + 24 tests green.
2026-09-19 07:34:48 +02:00
Dejvino 4b0f27da9a Ride: Fix hub caps 2026-09-18 19:19:57 +02:00
Dejvino 9c768f13d1 Venues: song sky, ride roadside, room camera, filmstrip venue pin
- Shared sky dome (src/venues/sky.js): gradient backdrop from palette /
  section light / style texture, wired into chamber/stage/ride with fog
  matched to the dome mid colour — replaces clear-to-black
- Ride roadside: houses, signs, trees, seeded viz billboard, all analytic
  scroll; popMul fade for crew, arrangement-gated roadside (no mid-section
  popping); parked idle creep + engine shake; analytic dash clock
- Room camera: traverse glide + progress look sweep + per-section dolly,
  single-side room visits, one re-hang max; venue framing per archetype
- Filmstrip venue selector (?venueSel=) with cache key; seed persisted to
  URL on reroll; gallery query plumbing
2026-09-18 18:57:04 +02:00
Dejvino 46fab512df Venues: persistent room, motivated camera, ride; drop Pylon Grid
- Venue as one continuous place: persistent per-song layer, interpolated
  venueState, story jumps (one re-hang max + recap) with camera cuts
- Room camera shot plan (venueShots.js): per-section size/target, monotonic
  look sweep, traverse glide body, room-side alternation
- New Ride venue: generated vehicle + actor passengers, radio RTT screen,
  chase/stationary modes, ride archetype across generator/camera/staging
- Flora cost trim (5x(12+6+6) -> 4x(8+4+4) SDF evals, 204ms -> 140ms worst)
- Delete Pylon Grid shader (2.5s/frame SwiftShader); drop its metadata row
- tools/venue-check.js headless gate harness + venue-gallery explorer
2026-09-17 19:35:26 +02:00
DejvinoandClaude b1b6431eeb OSD: stop appending "· llm-tuned" after applying LLM tweak
The corner title plate should stay clean — the tweak is already
confirmed via the Apply dialog and a toast. Appending to the fileName
made every LLM-tuned export carry an ugly suffix.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 18:56:52 +02:00
DejvinoandClaude 49914fd4ad LLM refine: 2dp prompt, mixed 64/32 frames, full proxy log, low reasoning
- spec.js: round all songSpec + extended summary/tempo/normalization to
  r2 (2dp) so high-precision 0.6607… stops wasting tokens; hoist r2/r3
  above specForPrompt to fix ReferenceError; FULL control wording kept.
- thumbs.js: border detection — first/last + 0.5s of any section start
  → 64×64 (borders), interior → 32×32 (between). Back-compat shim for
  old {size:N} callers. Returns size+role per frame for attachFrames.
- vite.config.js: raise sanitizeBodyForLog to 64k for __llm so 8×
  vision frames aren't truncated (was 16384, only 2 images logged).
  Also log reasoningEffort via providerOptions.
- llmTweak.js: set providerOptions reasoningEffort:'low' for both
  streamObject and generateObject fallback to cut 2459 → faster
  streaming progress, temperature 0.7 kept for variety.

Verification: build+lint:scenes green, tests 18/18.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 22:21:09 +02:00
DejvinoandClaude b0989169e2 LLM proxy: stream SSE through without buffering
The dev proxy buffered the entire gateway response via
arrayBuffer() before forwarding, which defeats streamObject —
no partials arrive until the full completion. Now event-stream
responses are streamed chunk-by-chunk via the upstream ReadableStream,
so the UI's "LLM streaming…" toast and console partials update live.
Non-streaming JSON is still buffered as before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 21:55:51 +02:00
DejvinoandClaude e0e619af85 LLM: streaming + rich context + 32×32 frames + proxy log
- Streaming: requestSongTweak now uses streamObject when a progress
  callback is provided (via handleLlmTweak), emitting partial {rationale,
  edits} and updating toast/console as text arrives; falls back to
  generateObject if gateway doesn't support streaming. Models always
  called via provider.chat(modelId) / /v1/chat/completions, the correct
  path for meta/muse-spark-1.3-contributor via gateway.

- Rich context: spec.js gains FULL control wording (any amount/arrangement/
  bpm allowed), and detailed payload — trackSummary with bandBalance,
  tempo, normalization (per-feature lo/hi), sectionsDetailed (energy/
  flux/centroid/flatness/width/slope/endEnergy/duration), stats
  (kinds, avg/max energy, bars). LLM told You have FULL control.

- Low-res vision: new src/llm/thumbs.js — captureThumbnails(show, {8,32})
  samples section midpoints, warms up state, renders via Show, box-averages
  down to 32×32 JPEG data URLs, restores timeline. main.js now captures 8
  frames with two animation frames of pause, then passes them as {type:
  image, image: dataUrl} parts in the user message (llmTweak.js
  attachFramesToMessages).

- Proxy logging (no bearer): vite.config.js llmGatewayProxy now logs
  sanitized requests/responses to logs/llm-proxy.log (JSON lines, 1MB
  rotation, redacts apiKey/authorization from path/body/headers) so the
  coding agent can inspect prompts without re-calling. .gitignore adds
  logs/.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 21:54:07 +02:00
DejvinoandClaude e2f56e3ce4 LLM: verbose [llm] logs + visible tweak progress
Tune appeared to do nothing — the 400's cause was buried, in-flight was
near-invisible, and dialog only appeared on success. Make every stage
observable and cheap to diagnose.

- src/llm/llmTweak.js (+ spec.js): provider.chat(model) + providerOptions
  strictJsonSchema:false fix (correct placement), edits nullable so strict
  requires all 7 keys; clamped, budget-aware. Now logs [llm] fetch target,
  messages/spec, raw vs clamped edits, rationale/confidence/usage,
  elapsed time, and diff summary. NO_EDITS refunded with console.warn.
- src/main.js: immediate OK toast "Contacting LLM (model)…" on click,
  console.group for handleLlmTweak start, post-result toast with
  rationale snippet + editsSummary before dialog, preview synthesis
  logged, catch logs stack once with toast.
- vite.config.js: /__llm dev proxy already in place from prior fix;
  retained for CORS-free dev.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:59:30 +02:00
DejvinoandClaude 82ab88e6b0 LLM: fix strict schema 400 — correct providerOptions + chat model
Previous attempt set `strictJsonSchema:false` on createOpenAI()
(factory ignores unknown keys), so wire was still strict:true and
sparse edits {brightness:0.3} were rejected: Missing 'arrangement'.

- src/llm/llmTweak.js: use provider.chat(modelId) (/chat/completions,
  correct for Meta via gateway, not /responses) and pass
  `providerOptions:{ openai:{ strictJsonSchema:false } }` on
  generateObject where the SDK actually reads it.
- src/llm/spec.js: edits fields nullable instead of optional — strict
  requires every key in `required` when `additionalProperties:false`;
  nullable makes required = all 7 (model emits null for unchanged),
  clampEdits already strips nulls so NO_EDITS still works. Prompt now
  instructs "use null for unchanged, example { bpm:null,... }".
- test/llm.test.js: minimal valid result now all-null with one edit.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:48:14 +02:00
DejvinoandClaude e8c04e7798 LLM: allow sparse optional edits — disable strict JSON schema
The Responses API via Vercel AI Gateway requires `required` to
include every key when `strict:true`. Our TweakEditsSchema has 7
optional song knobs, so sparse edits (e.g. only {brightness: 0.3})
were rejected with 400: Missing 'arrangement'.

- src/llm/llmTweak.js: createOpenAI({ strictJsonSchema: false }) so
  the gateway accepts optional properties; keeps zod validation +
  clampEdits as the real guardrail.
- src/llm/spec.js: drop .strict() on TweakEditsSchema — extra keys
  are stripped rather than erroring, more robust against model drift.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:30:54 +02:00
DejvinoandClaude a0eaf2ef95 Fix AI Gateway CORS by proxying through Vite dev server
Direct browser fetch to https://ai-gateway.vercel.sh/v1 is blocked
(No Access-Control-Allow-Origin). In dev the AI SDK now calls
/__llm/v1 via same-origin Vite middleware (llmGatewayProxy), which
forwards server-side to the real gateway — no CORS. Production still
calls the gateway directly (deployments needing CORS-free prod should
add a serverless /api/llm proxy).

- vite.config.js: new llmGatewayProxy middleware for /__llm/* —
  handles OPTIONS preflight, forwards via Node fetch to
  x-llm-gateway (fallback Vercel), mirrors status/headers +
  access-control-allow-origin, preserves query/body.
- src/llm/llmTweak.js: in DEV use baseURL /__llm/v1 + proxyFetch
  that injects x-llm-gateway; prod keeps cfg.gatewayUrl.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:25:19 +02:00
DejvinoandClaude 904ae09486 LLM: keep API key in localStorage only, default to muse-spark-1.3
Secrets must never be baked into the static build. Stop reading
VITE_AI_API_KEY from env — key comes only from localStorage via the
⚙️ config dialog (flow-state:llm). Gateway URL / model may still come
from env as non-secret defaults.

- src/llm/config.js: DEFAULTS model → meta/muse-spark-1.3-contributor
  (cheap, per user request), readEnv no longer touches apiKey,
  getLlmConfig resolves key only from stored.apiKey.
- .env.example: drop VITE_AI_API_KEY=, document browser-only key flow.
- src/main.js: dialog copy now says key is browser-only, never baked.

Build + lint:scenes + tests still green. No VITE_AI_API_KEY string
remains in source.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:08:06 +02:00
DejvinoandClaude b7c3537fcb LLM song-tweak: AI Gateway via Vercel AI SDK, budgeted tune flow
Add a ✨ tune button that lets an LLM inspect the proposed
visualization and tweak the proc-song knobs so the next generation
looks better. Edits only the 7 songbank params (bpm, brightness,
noise, noiseColour, dynamics, transition, arrangement) — never the
Look — then re-synthesizes and re-generates locally for preview.

- ai + @ai-sdk/openai + zod (library per plan) against the Vercel AI
  Gateway (OpenAI-compatible, configurable via
  VITE_AI_GATEWAY_URL/MODEL/API_KEY, localStorage override,
  .env.example).
- Structured output (TweakResultSchema) + clampEdits enforces ranges;
  prompt ≤ ~2.5k tokens, max 2-3 knobs ±0.15 / bpm ±8.
- Budget caps: 3/load + 10/session (sessionStorage), checked before
  network, NO_EDITS refunded, offline/auth/rate-limit mapped.
- Prompt split into src/llm/spec.js (pure, node-testable) + llmTweak.js
  (gateway + digest) to keep `node --test` off the Vite JSON path.
- UI: ⚙️ gateway/model/key dialog (localStorage flow-state:llm),
  confirm(prompt) with hint, diff modal with Apply/Dismiss; Apply
  synthesizes → FeatureTrack → generateLook → Show.useTrack.
- Tests: 7 new LLM cases (clamp, schema, prompt, budget), 18/18 pass.
  Build + lint:scenes green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:03:27 +02:00
DejvinoandClaude 7736390f9c Comfort: agitation ceiling, composition validator and whole-screen caps
Whole-screen flashing/coherent motion is now a gated comfort signal
rather than a hunt-and-tweak. Three-arm agitation (flash/3,
tile-motion/0.32, structure/0.12) over a 1s floating window, worst
window wins — engine/agitation.js.

Policy centralised in look/comfort.js: director comfort appetite,
per-scene agitationOf proxy, agitationWeight lean and stackAgitation.
Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth)
so one contract change tames every full-frame scene; lint warns above
the cap.

Selection now filters before casting (look/scene-filter.js, 12+ ranged
metrics from checks/scene-metrics.js, SCHEMA 7) and validates after
stacking (look/composition-validator.js) with a deterministic retune:
drop overlay → swap quiet composable → reselect filtered shot. Fix
validator attempt-3 ground-layer bug that crashed ?song=dusk
(LookGenerator now passes module, not layer).

Eight canvas shaders window their beat/flux (focusField + per-seed hash
mask) and report the scaled amount to Layer so the cap stays authoritative:
scan-tear, gate-corridor, analog-wow, block-mosh, metaballs,
pitch-shatter, scale-mosaic, tide-rings.

Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery
meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and
fingerprint over agitation/comfort sources. main.js surfaces
COMPOSITION_REFUSED as a user-facing error.

Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks.
Build passes. metadata.json refresh deferred to gallery → Refresh
metadata (SCHEMA 7 writer in vite.config).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 19:42:50 +02:00
136 changed files with 24483 additions and 471 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run lint:scenes *)"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# Vercel AI Gateway (non-secret defaults). The API key is NOT set here —
# it lives only in the browser's localStorage, entered via the ⚙️ dialog.
# Copy to .env.local to override defaults for gateway / model if needed.
VITE_AI_GATEWAY_URL=https://ai-gateway.vercel.sh/v1
VITE_AI_MODEL=meta/muse-spark-1.3-contributor
+2
View File
@@ -2,6 +2,8 @@ node_modules
dist
.vite
out
.env.local
logs/
# Scratch probes: one-off harnesses for debugging decode/encode/mux issues.
# They are throwaway by nature and were committed once by accident.
+43
View File
@@ -0,0 +1,43 @@
# AGENTS.md — flow-state
## Design decisions
All load-bearing decisions live in [`DESIGN.md`](./DESIGN.md). Read it before
changing behaviour. Changing a decision means updating `DESIGN.md` **and**
the gate that enforces it. `PLAN.md` is the full design; `EPIC-*.md` are the
epic designs.
## Adding / fixing a scene
Follow the `build-visualizer` skill (`.claude/skills/build-visualizer/`).
Short version:
```bash
npm run new:scene -- "Name" --family=<flow|organic|minimal|structural|geometric|glitch> --traits=...
# ... write the scene() body ...
npm run lint:scenes
# open http://localhost:5180/checks.html?scene=Name
```
Verify in order: `lint:scenes` (~1s) → per-scene gate in `checks.html` →
`checks.html?slow=1` once before committing. Each rung is ~10× the cost of
the previous.
## Rules that have bitten before (all in DESIGN.md §3–4)
- Anything multiplying `u_time` must be `rate: true` — and rate params are
never modulated (drift/reactive/lookahead skip them).
- Discrete params (`int`/`bool`/`discrete: true`) hold per cue and are
never reactive: **actors and objects change only at camera cuts.**
- No contract-uniform reuse, no hardcoded colours (use `pal()`), base
image under every `prev()`, honour every declared trait, feed `sigCamera`
into the image.
- No `Math.random` / wall-clock in render-path code; motion is analytic
`f(t, seed)` so seek === playback and preview === export.
## Checks
```bash
npm test # audio pipeline + venue/model headless tests
npm run lint:scenes # determinism grep + schema/shader agreement
```
+178
View File
@@ -0,0 +1,178 @@
# flow-state — design decisions
The decisions this project has taken, and why. New work must obey them;
changing one means updating this file and the gate that enforces it.
Doc map: `PLAN.md` is the full design, `EPIC-2/3/4/5-3D.md` are the epic
designs, `HOWTO-visualizers.md` / `HOWTO-variety.md` are the scene-author
references, `MIGRATION.md` is the shader→stage migration recipe.
## 1. Determinism spine
- The audio is fully analysed **before the first frame renders**, into a
frame-indexed `FeatureTrack` table. Nothing reads a live `AnalyserNode`.
Preview maps `currentTime → frameIndex`; export counts frames. Same rows,
same image.
- No wall-clock (`performance.now`, `Date`) and no `Math.random` in
`engine/scenes/look/audio/params/actors/venues`. Seeded `Rng` only;
motion is analytic `f(t, seed)` — seek === playback, preview === export.
- `dt` is constant (`1/fps`); dropped preview frames hitch, never diverge.
- Feedback/ping-pong targets are explicitly cleared; shader programs are
primed (`Engine.prime`) before frame-exact rendering — unlinked programs
rendered the first ~10 frames wrong on the heaviest scene.
- Scene math lives in normalized aspect-corrected coords (`uv`, `p`); only
`u_pixelScale` carries pixels, so 720p preview matches 4K export.
- Guarantee is **per-machine** bit-identical; cross-machine is perceptual.
Same-machine allows max channel delta ≤ 1 (GPU load varies one LSB under
heavy shaders); real bugs score in the tens.
- Seeks need **warm-up** (`log(0.001)/log(decay)` frames) whenever feedback
is on. Section boundaries are NOT exact for free — the compositor's
feedback buffer is global and carries across them. Clearing it per
boundary would flash every transition; warm-up is the better trade.
- Previews link to their exact app frame (`src/ui/preview.js`: song + seed
+ frame + venue/viz pins + the probe's warm-up length, both sides primed).
Exactness takes four agreements: the bank probes analyse the bank PCM
(peak-normalised 16-bit mono — `audio/songbank.js bankPcm`), file decode
is fixed at 44.1 kHz with bit-exact WAV parsing (never the context rate),
pins run before any layer builds (rebuilding mid-context renders
differently), and materials built mid-update compile synchronously
(`needsCompile`) so no warm-up frame draws against an unlinked program.
## 2. Lookahead
Offline analysis is what buys anticipation: `buildSlope` ramps through the
bars before a higher-energy section, so visuals arrive at the drop already
at tension. A causal analyser can never do this. The outgoing layer of a
crossfade keeps its pre-boundary slope — feeding it the new section's
features collapses its ramp in one frame (a visible pop at the edit).
## 3. Scenes: shader + schema, nothing else
- A scene is a fragment shader (or model build/update) plus a params block.
Uniform binding, UI sliders, seeded sampling, arc drift and phase gates
are all derived from the schema. No per-scene wiring.
- Six families (`flow geometric organic structural minimal glitch`) chosen
by section kind — a breakdown never lands on a strobing glitch scene.
- Personality traits (`shape camera space style`) are a **contract**: each
track is built on 1–2 traits and a scene honouring fewer is never cast.
The library intentionally shrinks per track. Declared traits need
machine-checked evidence (lint grep + render-delta gate); a dead camera
(`sigCamera` assigned, result ignored) shipped once and is now linted.
- `rate: true` is mandatory for anything multiplying `u_time`. Phase is
`elapsed × rate`; modulating a rate jumps phase by `elapsed × Δrate` —
measured as 6 flashes/s, twice the WCAG 2.3.1 ceiling.
- Whole-frame luminance never pulses on the beat (3-flashes/s ceiling).
Pulse something local; quantise glitches to the bar grid.
- A scene reading `prev()` must paint a base image first (black first
frames, seek-fragile otherwise). `prev()` readers can never be grounds.
- No contract-uniform collisions, no hardcoded saturated colours — the
look generator recolours through `pal()`/`palRamp()` only.
- Loops need constant bounds with an early `break` on the deciding param
(`// lint: fixed-cost` opt-out for genuinely fixed work).
## 4. Continuity: nothing disappears mid-shot
Actors and objects change only at camera cuts:
- **Discrete params** (`int`/`bool`, plus floats marked `discrete: true`
that drive loop bounds / instance counts) hold constant for the whole
cue. No drift, no lookahead ramp, no reactive modulation — those would
pop members in/out mid-shot. They still vary per section/variant sample,
so drops can be denser than intros; the change lands on a cut.
- A discrete param MAY declare `slowAxis`: the driver steps its travel on
the section-held journey instead of sliding it.
- Reactive on a discrete param and (unstated) drift of one are lint errors.
- Venue furniture never hard-toggles on interpolated state: chamber junk
and stage pylons fade (ride-crew pattern); ride roadside density is
arrangement-only. Re-hangs happen only on story jumps, always under a
camera cut (`venueJumpAt`); the venue layer itself is persistent and
never dissolved (old-room/new-room ghosting read as a glitch).
## 5. Time structure: cues, cuts, camera
- The arc driver works in **cues** (one per shot): a shot cut and a section
change take the same code path, differing only in transition length.
- Default transition is a slow dissolve (2 bars calm, 1 bar loud, capped
at 40% of the shot). Hard cuts only above the energy threshold — on calm
material a cut reads as a glitch. Even hard cuts are 2-frame ramps (a
1-frame swap of bright scenes is a flash).
- Framing lives at the shot level (size constant per shot, gaze live);
the outgoing shot keeps travelling as it fades — freezing it stops the
old image dead before it disappears.
- Sharp transitions cut colour instantly with the image and always reframe
(a bigger reframe than a dissolve earns).
- Story variables are **staged, not ramped**: flat inside a section,
blended ~1 bar after boundaries. Moments (arrival/turn/climax/resolution)
are found in the audio; the story never overrides the song (bounded
bias terms). All scenes share one axis sign so the video travels one way.
## 6. Colour and variety
- Palettes in OKLCH; hue from **spectral tilt** (log treble/body ratio —
centroid and plain body-fraction both collapsed across masters); both
ways round the wheel; directors choose 1–4 palettes, blended in OKLCH
across dissolves.
- Per-track **temperament** (intensity/pace/detail/extremity) moves every
param dial so one scene cast in two videos isn't the same footage twice.
- **Overlays** on 5–55% of stacks, never `normal` blend, never above 0.6
opacity — occasionally layered, never permanently cluttered.
- A scene varies when the SONG changes what's on screen: draw
`castMain`/`castChorus` (`form` in 3D), size via `stageScale()`, place
via `stageNode()`. No effect-knobs (glow/grain/chroma belong to post).
- Grain is a per-track **treatment** (mode/cell/rate/mask/chroma, ~45% of
tracks get none); scene `texture: 0..2` scales only its share.
- Absolute animation speed follows the song: motion bias is
tempo-dominated and every `rate: true` param scales by per-track
`rateScale` — slow songs got fast scenes before this.
- Venues have a per-track **character** the same way: the dungeon derives
sealed/murky/big (`dungeonCharacter`: tonal darkness, doubling-compensated
tempo, stillness, weight) from the measured summary, calibrated against
the bank because arrangement intent doesn't survive measurement (ambient
doubles tempo, hiss reads bright). Dark slow songs seal fully indoor;
blazing masters always crack open.
- The dungeon also gets a per-track visual **theme** (`dungeonTheme`:
crypt/overgrown/ember/flooded/windswept), scored off the same summary
traits — one album must not render the same dungeon twice. Pace terms
gate on tempoConfidence (doubled detectors must not read fast), jitter
is small enough that decided races stay decided across seeds.
## 7. Grounds and composition
- `surface` is measured (`metadata.json`), never declared — nine scenes
claimed `canvas` while painting under a third of the frame.
- A ground fills the frame on average and never vanishes under any
identity (worst case counts, not the mean); hollow grounds are
flattened via `groundPersonalityFrom`.
- Model layers share one `Compositor` perspective/depth rig; shader depth
stays faked. Venue rooms are furnished with the track's monolith body
so the room reads as this track's place.
- With a room present the viz plays ON its screens (window/wall/radio),
never composited over it; model viz additionally stand in the shared
world for shared-rig rooms. A `directCamera` room (ride) owns only its
own rig — the shared rig keeps the subject grammar, or the viz is filmed
through a room camera meant for another world and vanishes.
## 8. 3D on the same terms
- `ActorSpec` is data, not scene graph (serialisable, hashable); geometry
via `actorToGeometry` from the same `Identity.form` the shaders march.
- Model motion is analytic, instanced, seeded; kit deformation reuses the
song's silhouette params; `castSolid` impostors remain the fallback.
- New actors are a file plus a registry entry, gated like scenes.
## 9. Export and review
- WebCodecs primary, frame-dump+ffmpeg fallback; title card, edge fades,
progress/cancel; A/V sync within one frame; survives a real upload test.
- Preview is the primary surface: transport, section jump/loop, live param
edit, timeline strip, re-roll + locks, draft/full quality, 20s
segment test-renders. Never commit to a full render blind.
- Every phase gate is measured; the battery (6 tracks ambient→hard EDM)
is watched end to end — monotony is invisible to automation.
## 10. History
- Forked from `party-stage`, then detached: zero cross-imports.
- Corrections kept: boundary seeks need warm-up (not exact for free);
`layout` is blind on full-frame fields; aggregate ratios (e.g.
`separation`) swing run-to-run — steer by direct render comparisons.
+169
View File
@@ -33,6 +33,22 @@
pre { padding: 8px 10px; overflow-x: auto; margin: 6px 0 0; }
.note { color: #6b7280; font-size: 12px; max-width: 80ch; }
.warn { color: #eab308; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
.chips a {
background: #080a0f; border: 1px solid #1a1e28; border-radius: 3px;
padding: 1px 7px; font-size: 12px; white-space: nowrap;
}
.chips a:hover { border-color: #7dd3fc; text-decoration: none; }
.chips .fam { width: 100%; color: #565f70; font-size: 11px; letter-spacing: .08em;
text-transform: uppercase; margin-top: 6px; }
.chips .fam:first-child { margin-top: 0; }
.links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.links a {
background: #080a0f; border: 1px solid #1a1e28; border-radius: 3px;
padding: 1px 7px; font-size: 12px;
}
.links a:hover { border-color: #7dd3fc; text-decoration: none; }
.links .lbl { color: #565f70; font-size: 12px; align-self: center; }
</style>
</head>
<body>
@@ -43,6 +59,48 @@
many.
</p>
<h2>Vis testing battery</h2>
<p class="lede">
The testing library: every bank song, every scene, every venue, the
param/scenario combos worth re-running, and the lab pages. Rendered
from the live registries (<code>src/debug/battery.js</code>), so a new
song, scene or venue joins the battery for free.
</p>
<div class="grid">
<div class="card fast">
<h3>songs — the bank, one click each</h3>
<p>Each song holds a corner of the feature space (<code>covers</code>).
App loads the song; +venue adds the room; strip is one frame per
30 s across the whole song.</p>
<div id="battery-songs" class="chips"><span class="note">loading…</span></div>
</div>
<div class="card fast">
<h3>venues — every room</h3>
<p>Pinned gallery first (fastest way to review a venue change), then
the app and the filmstrip with the room on.</p>
<div id="battery-venues"><span class="note">loading…</span></div>
</div>
</div>
<h2>Scenes — every visualizer</h2>
<p class="note">
Gate is the per-scene acceptance battery (renders, animates,
deterministic, distinct, sweep, flash, traits); wide is the scene on
six songs × six seeds.
</p>
<div class="grid">
<div class="card fast">
<h3>by family</h3>
<div id="battery-scenes" class="chips"><span class="note">loading…</span></div>
</div>
</div>
<h2>Scenarios — params and combos</h2>
<div class="grid" id="battery-scenarios"></div>
<h2>Lab pages + headless runners</h2>
<div class="grid" id="battery-labs"></div>
<h2>Looking at output</h2>
<div class="grid">
<div class="card fast">
@@ -78,6 +136,13 @@
<a href="/filmstrip.html?song=centre,ember">?song=</a> just these ·
<code>EPIC-4.md</code> for the arc it is checking</div>
</div>
<div class="card fast">
<h3><a href="/venue-gallery.html">venue gallery</a></h3>
<p>Venue &times; viz explorer — venue (higher-level than viz, picked first via census; hybrid
projection per archetype) against viz behind it. Try a sad-slow chamber of memories vs. a
pumping stage field from different angles and with different viz.</p>
<div class="meta">rows = songs · try <code>?venue=1</code> in gallery/filmstrip/app</div>
</div>
<div class="card fast">
<h3><a href="/">the app</a></h3>
<p>Drop a track and watch it. <code>test/songs/*.wav</code> holds the
@@ -205,5 +270,109 @@ npm test</pre>
session over a file called <code>clicktrack.js</code> — or a dev server
caught mid-restart.
</p>
<script type="module">
import {
songNames, songCovers, families, scenesIn,
venueNames, appLinks, sceneLinks, venueLinks,
SCENARIOS, LABS,
} from '/src/debug/battery.js';
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
};
const link = (href, text) => {
const a = document.createElement('a');
a.href = href;
a.textContent = text;
return a;
};
// Songs: one row per bank song — name, corner it holds, app / +venue / strip.
{
const host = document.getElementById('battery-songs');
host.replaceChildren();
for (const name of songNames()) {
const covers = songCovers(name);
const L = appLinks(name);
const row = el('span', 'fam', `${name} — ${covers} `);
host.appendChild(row);
host.appendChild(link(L.app, 'app'));
host.appendChild(link(L.venue, '+venue'));
host.appendChild(link(`/filmstrip.html?song=${encodeURIComponent(name)}`, 'strip'));
}
}
// Venues: pinned gallery + app + filmstrip per room.
{
const host = document.getElementById('battery-venues');
host.replaceChildren();
for (const name of venueNames()) {
const L = venueLinks(name);
const card = el('div', null);
card.appendChild(el('p', null, name));
const row = el('div', 'links');
row.appendChild(link(L.gallery, 'gallery'));
row.appendChild(link(L.app, 'app +venue'));
row.appendChild(link(L.filmstrip, 'strip'));
card.appendChild(row);
host.appendChild(card);
}
}
// Scenes: one chip per visualizer (→ gate), ◈ per scene (→ wide gallery).
{
const host = document.getElementById('battery-scenes');
host.replaceChildren();
for (const fam of families()) {
host.appendChild(el('span', 'fam', fam));
for (const name of scenesIn(fam)) {
const L = sceneLinks(name);
host.appendChild(link(L.gate, name));
const w = link(L.wide, '◈');
w.title = `${name} — wide gallery (6 songs × 6 seeds)`;
host.appendChild(w);
}
}
}
// Scenarios: curated cards from the library.
{
const host = document.getElementById('battery-scenarios');
host.replaceChildren();
for (const s of SCENARIOS) {
const card = el('div', 'card');
card.appendChild(el('h3', null, s.title));
card.appendChild(el('p', null, s.desc));
const row = el('div', 'links');
for (const [label, href] of s.links) row.appendChild(link(href, label));
card.appendChild(row);
host.appendChild(card);
}
}
// Labs: pages + the CLI that drives them.
{
const host = document.getElementById('battery-labs');
host.replaceChildren();
for (const lab of LABS) {
const card = el('div', 'card cli');
card.appendChild(el('h3', null, lab.title));
card.appendChild(el('p', null, lab.desc));
if (lab.href) {
const row = el('div', 'links');
row.appendChild(link(lab.href, 'open'));
card.appendChild(row);
}
if (lab.cli) {
const pre = el('pre', null, lab.cli);
card.appendChild(pre);
}
host.appendChild(card);
}
}
</script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · dungeon debug</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, monospace; }
h1 { font-size:15px; color:#7d8698; margin:0 0 4px; }
h2 { font-size:12px; color:#6b7280; margin:22px 0 8px; text-transform:uppercase; letter-spacing:.1em; }
#status { color:#8b94a7; margin: 8px 0 16px; }
#status b { color:#e6eaf2; }
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
#map { background:#05070a; border:1px solid #1c2030; image-rendering:pixelated; }
#mapmeta { max-width:340px; }
#mapmeta .kv { display:flex; justify-content:space-between; gap:12px; padding:1px 0; }
#mapmeta .kv span { color:#8b94a7; }
.legend { margin-top:10px; font-size:12px; color:#8b94a7; }
.legend i { display:inline-block; width:10px; height:10px; margin-right:5px; vertical-align:baseline; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:10px; }
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
.card h3 .mode-walk { color:#7dd3fc; } .card h3 .mode-room { color:#a3e635; } .card h3 .mode-portal { color:#f0abfc; }
canvas.still { width:100%; display:block; background:#05070a; aspect-ratio:16/9; }
a { color:#7dd3fc; }
</style>
</head>
<body>
<h1>flow-state · dungeon debug</h1>
<div id="status">starting…</div>
<h2>map — <span id="maptitle"></span></h2>
<div class="wrap">
<canvas id="map" width="440" height="440"></canvas>
<div id="mapmeta"></div>
</div>
<h2>stills — song timeline, both camera modes</h2>
<div id="out" class="grid"></div>
<script type="module">
import { Engine } from '/src/engine/Engine.js';
import { createLayer } from '/src/engine/Layer.js';
import { song } from '/src/audio/songbank.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { venues } from '/src/venues/registry.js';
import { dungeon } from '/src/venues/dungeon.js';
import { genDungeon } from '/src/venues/dungeon/map.js';
import { hashString } from '/src/engine/rng.js';
import { blit } from '/src/checks/blit.js';
import * as THREE from 'three';
const out = document.getElementById('out');
const status = document.getElementById('status');
const params = new URLSearchParams(location.search);
const songName = params.get('song') || 'ember';
const mapSeedParam = params.get('mapseed');
const TIMES = (params.get('times') || '5,25,45,65,85,105').split(',').map(Number);
const { track } = song(songName);
const look = generateLook(track, { seed: hashString(songName), venueEnabled: true });
look.venue.module = dungeon;
look.venue.name = dungeon.name;
look.venue.archetype = dungeon.archetype;
const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7);
const map = genDungeon({ seed: mapSeed, summary: track.summary });
look.venue.dungeon = { map };
// ---------------------------------------------------------- 2D map
const ZC = { hall: '#232b3d', room: '#2b3450', forest: '#14301c', wallwalk: '#33302a' };
const mc = document.getElementById('map');
const mg = mc.getContext('2d');
const S = mc.width / map.w;
document.getElementById('maptitle').textContent = `${songName} · map seed ${mapSeed} · ${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u`;
const ch = map.character || {};
const chLine = `<div class="kv"><span>character</span><b>seal ${(ch.indoorBias ?? 0).toFixed(2)} · murk ${(ch.murk ?? 0).toFixed(2)} · size ×${(ch.sizeMul ?? 1).toFixed(2)}</b></div>` +
`<div class="kv"><span>theme</span><b>${map.theme ?? 'crypt'}</b></div>`;
for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) {
if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; }
else {
const r = map.rooms.find((rm) => x >= rm.x && x < rm.x + rm.w && y >= rm.y && y < rm.y + rm.h);
mg.fillStyle = r ? (ZC[r.zone] || '#222') : '#1a2030';
}
mg.fillRect(x * S, y * S, S, S);
}
}
// Route.
mg.strokeStyle = '#f5b942'; mg.lineWidth = 2; mg.beginPath();
map.route.pts.forEach((p, i) => {
const cx = (p.x / 2 + map.w / 2) * S, cy = (p.z / 2 + map.h / 2) * S;
if (i === 0) mg.moveTo(cx, cy); else mg.lineTo(cx, cy);
});
mg.closePath(); mg.stroke();
const dot = (x, z, color, r = 3) => {
mg.fillStyle = color; mg.beginPath();
mg.arc((x / 2 + map.w / 2) * S, (z / 2 + map.h / 2) * S, r, 0, 7); mg.fill();
};
for (const p of map.items.props) dot(p.x, p.z, '#8b94a7', 2);
for (const p of map.items.puddles) dot(p.x, p.z, '#38bdf8', 3);
for (const p of map.items.torches) dot(p.x, p.z, '#fb923c', 2);
for (const p of map.items.windows) dot(p.x, p.z, '#22d3ee', 2);
for (const p of map.items.gates) dot(p.x, p.z, '#ef4444', 3);
for (const v of map.items.vegetation || []) {
dot(v.x, v.z, v.kind === 'boulder' ? '#6b7280' : '#166534', 2);
}
const ECOL = { lurker: '#f87171', sentry: '#fb923c', critter: '#facc15', wisp: '#a5f3fc' };
for (const e of map.items.entities || []) {
dot(e.x, e.z, ECOL[e.kind] || '#f87171', 3);
mg.fillStyle = '#0b0d12';
mg.fillText(e.kind[0].toUpperCase(), (e.x / 2 + map.w / 2) * S - 3, (e.z / 2 + map.h / 2) * S + 3);
}
for (const l of map.items.loot || []) {
dot(l.x, l.z, '#fde047', 3);
mg.strokeStyle = '#fde047'; mg.lineWidth = 1; mg.beginPath();
mg.arc((l.x / 2 + map.w / 2) * S, (l.z / 2 + map.h / 2) * S, 5, 0, 7); mg.stroke();
}
for (const t of map.items.timbers || []) {
dot(t.x, t.z, '#92400e', 2);
}
if (map.portal) {
dot(map.portal.x, map.portal.z, '#f0abfc', 5);
mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath();
mg.arc((map.portal.x / 2 + map.w / 2) * S, (map.portal.z / 2 + map.h / 2) * S, 7, 0, 7); mg.stroke();
}
mg.fillStyle = '#a3e635';
for (const w of Object.values(map.watch)) {
const cx = (w.x / 2 + map.w / 2) * S, cy = (w.z / 2 + map.h / 2) * S;
mg.fillRect(cx - 3, cy - 3, 6, 6);
}
mg.fillStyle = '#fff';
map.rooms.forEach((rm) => {
mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S);
});
document.getElementById('mapmeta').innerHTML = chLine +
map.rooms.map((rm) =>
`<div class="kv"><span>room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}</span><b>${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}</b></div>`).join('') +
`<div class="legend">
<div><i style="background:#f5b942"></i>route (closed tour)</div>
<div><i style="background:#a3e635"></i>watch cell (room camera plant)</div>
<div><i style="background:#f0abfc"></i>portal · <i style="background:#ef4444"></i>gate ·
<i style="background:#fb923c"></i>torch · <i style="background:#22d3ee"></i>window ·
<i style="background:#38bdf8"></i>puddle · <i style="background:#8b94a7"></i>prop ·
<i style="background:#166534"></i>veg · <i style="background:#6b7280"></i>boulder ·
<i style="background:#f87171"></i>lurker · <i style="background:#fb923c"></i>sentry ·
<i style="background:#facc15"></i>critter · <i style="background:#a5f3fc"></i>wisp ·
<i style="background:#fde047"></i>loot (halt) · <i style="background:#92400e"></i>timber</div></div>`;
// ---------------------------------------------------------- 3D stills
// Fake viz feed so windows/portal/puddles show their IMAGE (the compositor
// would bind the real viz texture; the lab stands in a test pattern).
function fakeViz() {
const c = document.createElement('canvas');
c.width = 256; c.height = 144;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 256, 144);
grad.addColorStop(0, '#0ff'); grad.addColorStop(0.5, '#80f'); grad.addColorStop(1, '#f0f');
g.fillStyle = grad; g.fillRect(0, 0, 256, 144);
g.fillStyle = '#fff';
for (let i = 0; i < 24; i++) g.fillRect((i * 53) % 256, (i * 97) % 144, 20, 8);
return c;
}
const vizCanvas = fakeViz();
const engine = new Engine({ width: 480, height: 270 });
const layer = createLayer(dungeon, {
params: look.venue.params || {},
seed: look.venue.seed ?? 7,
opacity: 1, blend: 'normal',
actorSpec: look.actors?.monolith || null,
venueSpec: look.venue,
});
layer.setPalette(look.palette);
layer.setPersonality(look.personality);
if (layer.setActor && look.actors?.monolith) layer.setActor(look.actors.monolith);
if (layer.setVenue) layer.setVenue(look.venue);
engine.compositor.setLayers([layer]);
function bindFakeViz() {
const tex = new THREE.CanvasTexture(vizCanvas);
dungeon.bindVizTexture(layer.instance, tex);
}
bindFakeViz();
const fps = track.fps || 60;
const modes = [
{ target: 'walk', label: 'walk · first-person (actor hidden)' },
{ target: 'room', label: 'room · plant watches actor' },
];
for (const t of TIMES) {
const frame = Math.round(t * fps);
const feat = track.at ? track.at(frame) : {};
const tl = { frame, time: frame / fps, progress: frame / (track.frameCount || 1) };
for (const m of modes) {
layer.setFraming({
scale: 1, shift: [0, 0],
venueShot: { target: m.target, pos: [0, 1, 2], look: [0, 0.4, -5], size: 'normal', dolly: 1.0 },
});
for (let k = 0; k < 4; k++) {
layer.render(engine.renderer, engine.compositor.layerTarget, {
timeline: { ...tl, frame: frame - (3 - k), time: (frame - (3 - k)) / fps },
features: feat, prevTexture: null,
});
}
layer.render(engine.renderer, engine.compositor.layerTarget, {
timeline: tl, features: feat, prevTexture: null,
});
const pix = Uint8Array.from(engine.readPixels(engine.compositor.layerTarget));
const card = document.createElement('div');
card.className = 'card';
// Where the actor actually is: nearest route sample to the built hero.
const hp = layer.instance.hero.position;
let bi = 0, bd = Infinity;
map.route.pts.forEach((p, i) => {
const d = Math.hypot(p.x - hp.x, p.z - hp.z);
if (d < bd) { bd = d; bi = i; }
});
const rid = map.route.roomIds[bi];
const zn = map.route.zones[bi];
card.innerHTML = `<h3>t=${t}s · <span class="mode-${m.target}">${m.target}</span> · ` +
(rid >= 0 ? `room ${rid} (${zn})` : `corridor (${zn})`) + `</h3>`;
const cv = document.createElement('canvas');
cv.width = 480; cv.height = 270; cv.className = 'still';
card.appendChild(cv); out.appendChild(card);
blit(cv, pix, 480, 270);
}
}
status.innerHTML = `done — <b>${songName}</b> · map seed <b>${mapSeed}</b> · ` +
`${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u · ` +
`${TIMES.length * modes.length} stills`;
window.__DUNGEON_DEBUG_DONE__ = true;
</script>
</body>
</html>
+79 -12
View File
@@ -39,6 +39,15 @@
.row.churn { border-color: #eab308; }
.row.arc { border-color: #22c55e; }
.row.broken { border-color: #a855f7; background: #150e1c; }
.row.agitated { border-color: #f97316; background: #1a1210; }
.agit { color: #9aa3b5; font-size: 11px; }
.agit .meter { display: inline-block; width: 96px; height: 7px; background: #1a1e28; border-radius: 4px; vertical-align: middle; margin-right: 6px; }
.agit .meter i { display: block; height: 100%; border-radius: 4px 0 0 4px; }
.agit .meter i.low { background: #22c55e; }
.agit .meter i.mid { background: #eab308; }
.agit .meter i.high { background: #f97316; }
.agit b.agitated { color: #f97316; }
.agit b.calm { color: #22c55e; }
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.name { font-size: 14px; color: #e6eaf2; }
.meta { color: #6b7280; font-size: 11px; }
@@ -54,6 +63,8 @@
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
.strip figure { margin: 0; flex: 0 0 auto; width: 168px; }
.strip canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
a.preview-link canvas { cursor: zoom-in; }
a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
.strip figcaption { font-size: 10px; color: #565f70; margin-top: 3px; line-height: 1.35; }
.strip .t { color: #8b94a7; }
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -79,9 +90,11 @@
<div class="bar" id="bar">
<span class="spinner"></span>
<button id="rebuild">rebuild</button>
<select id="venueSel" title="pin one venue in every row, or census"></select>
<select id="sort">
<option value="direction">sort: least direction first</option>
<option value="drift">sort: least drift first</option>
<option value="agitation">sort: most agitating first</option>
<option value="name">sort: bank order</option>
<option value="plot">sort: by plot</option>
</select>
@@ -92,9 +105,11 @@
<div id="out"></div>
<script type="module">
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
import { buildFilmstrip, songNames, STRIP, WARMUP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
import { venues } from '/src/venues/registry.js';
import { blit } from '/src/checks/blit.js';
import { hashString } from '/src/engine/rng.js';
import { previewLink } from '/src/ui/preview.js';
import {
sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas,
@@ -104,6 +119,8 @@ const params = new URLSearchParams(location.search);
const every = Number(params.get('every')) || DEFAULT_EVERY;
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
const only = params.get('song') ? params.get('song').split(',') : null;
const venueEnabled = params.get('venue') === '1';
const venueSelParam = params.get('venueSel') || '';
document.getElementById('everyLabel').textContent = every;
@@ -111,23 +128,58 @@ const bar = document.getElementById('bar');
const out = document.getElementById('out');
const status = document.getElementById('status');
const sortSel = document.getElementById('sort');
const venueSel = document.getElementById('venueSel');
const progress = document.querySelector('#progress i');
const rebuildBtn = document.getElementById('rebuild');
venueSel.innerHTML = `<option value="">all venues (census)</option>` +
venues.map((v) => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
if ([...venueSel.options].some((o) => o.value === venueSelParam)) venueSel.value = venueSelParam;
venueSel.addEventListener('change', () => {
const p = new URLSearchParams(location.search);
if (venueSel.value) p.set('venueSel', venueSel.value);
else p.delete('venueSel');
history.replaceState(null, '', `${location.pathname}?${p}`);
build();
});
let rows = [];
// Read against the arcless reference the variety report prints: around zero is
// a video that is as unlike itself after ten seconds as after four minutes.
const grade = (row) => (row.error ? 'broken'
: row.direction < 0.15 ? 'static'
: row.direction < 0.45 ? 'churn' : 'arc');
const AGIT_CEILING = 0.70;
const agitClass = (v) => (v == null ? 'low' : v >= AGIT_CEILING ? 'high' : v >= 0.50 ? 'mid' : 'low');
const grade = (row) => {
if (row.error) return 'broken';
if ((row.agitation ?? 0) >= AGIT_CEILING) return 'agitated';
return row.direction < 0.15 ? 'static' : row.direction < 0.45 ? 'churn' : 'arc';
};
// Nine stills are an argument about a video, and the only reply to them is the
// video. The app takes the bank's wav straight from `?song=`, and the seed is
// the one renderSong() uses — hashed off the name — so what plays is the take
// the strip measured rather than a fresh draw that happens to share a title.
const watchLink = (name) =>
`/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
// The venue flag and pin ride along too, so a venue strip replays its room.
const watchLink = (name) => {
const p = new URLSearchParams();
p.set('song', name);
p.set('seed', String(hashString(name)));
if (venueSel.value || venueEnabled) p.set('venue', '1');
if (venueSel.value) p.set('venueSel', venueSel.value);
return `/?${p}`;
};
// Deep link for one figure: song + seed + the frame it shows, so clicking a
// still opens the exact image in the app. Built by the shared preview
// component (src/ui/preview.js) — every preview page links the same way.
const shotSpec = (rowName, shot) => ({
song: rowName,
seed: hashString(rowName),
frame: shot.frame,
venue: venueEnabled || !!venueSel.value,
venueSel: venueSel.value || null,
warm: WARMUP,
});
/**
* One song's row.
@@ -140,13 +192,19 @@ const watchLink = (name) =>
function renderRow(row) {
const el = document.createElement('div');
el.className = `row ${grade(row)}`;
const agit = row.agitation ?? 0;
const agitLabel = row.error ? '' :
` <span class="agit" title="worst 1s window in busiest section — max(flash/3, motion/0.32, struct/0.12). Whole-screen reserved for specials; region motion fine. Ceiling ${AGIT_CEILING.toFixed(2)}">` +
`<span class="meter"><i class="${agitClass(agit)}" style="width:${Math.min(100, agit*100)}%"></i></span> ` +
`agit <b class="${agit >= AGIT_CEILING ? 'agitated' : 'calm'}">${agit.toFixed(2)}</b> · flash ${(row.agitationFlash ?? 0).toFixed(1)}/s · motion ${((row.agitationMotion ?? 0)*100).toFixed(0)}% · struct ${(row.agitationStructPeak ?? row.agitationStruct ?? row.peakStructure ?? 0).toFixed(2)}` +
`</span>`;
el.innerHTML = `
<div class="head">
<span class="name">${row.name}</span>
<a class="watch" href="${watchLink(row.name)}" target="_blank"
title="play this song in the app, on the seed this strip was rendered with">watch ▸</a>
<span class="meta">${row.bpm ?? '?'} bpm · ${row.sections ?? '?'} sections · ${row.director || ''}</span>
<span class="num">direction <b>${(row.direction ?? 0).toFixed(2)}</b> · drift <b>${(row.drift ?? 0).toFixed(3)}</b></span>
<span class="num">direction <b>${(row.direction ?? 0).toFixed(2)}</b> · drift <b>${(row.drift ?? 0).toFixed(3)}</b>${agitLabel}</span>
<span class="story">${row.error ? `<span class="err">${row.error}</span>` : (row.storyLine || '')}</span>
</div>
<div class="strip"></div>`;
@@ -156,6 +214,8 @@ function renderRow(row) {
if (shot.moment === 'climax') fig.className = 'climax';
const canvas = document.createElement('canvas');
fig.appendChild(canvas);
// Clicking a still opens the exact frame in the app.
if (Number.isFinite(shot.frame)) previewLink(canvas, shotSpec(row.name, shot));
const cap = document.createElement('figcaption');
const mm = Math.floor(shot.time / 60);
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
@@ -183,7 +243,8 @@ function draw() {
const mode = sortSel.value;
const order = songNames();
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? order.indexOf(a.name) - order.indexOf(b.name)
mode === 'agitation' ? (b.agitation ?? 0) - (a.agitation ?? 0)
: mode === 'name' ? order.indexOf(a.name) - order.indexOf(b.name)
: mode === 'drift' ? a.drift - b.drift
: mode === 'plot' ? ((a.story?.plot || '').localeCompare(b.story?.plot || '') || a.direction - b.direction)
: a.direction - b.direction));
@@ -196,16 +257,22 @@ sortSel.addEventListener('change', draw);
function summarise(built) {
const flat = rows.filter((r) => !r.error && r.direction < 0.15).length;
const validatedNote = rows.some((r) => r.validatedNote) ? ' · ' + rows.filter((r) => r.validatedNote).length + ' re-tuned' : '';
const agited = rows.filter((r) => (r.agitation ?? 0) >= AGIT_CEILING).length;
const meanDir = rows.length
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
return `${rows.length} songs · every ${every}s of ${duration}s · ` +
const meanAgit = rows.length
? rows.reduce((a, r) => a + (r.agitation ?? 0), 0) / rows.length : 0;
const venueNote = venueSel.value ? ` · venue ${venueSel.value}` : (venueEnabled ? ' · venues from census' : '');
return `${rows.length} songs · every ${every}s of ${duration}s${venueNote} · ` +
`mean direction ${meanDir.toFixed(2)} · ${flat} with no direction` +
` · mean agit ${meanAgit.toFixed(2)} · ${agited} agitated (≥${AGIT_CEILING.toFixed(2)})${validatedNote}` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
}
// The cache key carries the sampling parameters as well as the source, so
// changing `?every=` does not silently show you the previous spacing's pixels.
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:${sourceFingerprint()}`;
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:v${venueEnabled ? 1 : 0}:${venueSel.value || 'auto'}:${sourceFingerprint()}`;
async function build() {
bar.classList.add('building');
@@ -215,7 +282,7 @@ async function build() {
const total = (only || songNames()).length;
const built = await buildFilmstrip({
duration, every, names: only,
duration, every, names: only, venueEnabled, venueSel: venueSel.value || null,
// Painted as they land, in bank order, rather than after the whole run.
// A full build is a couple of minutes of GPU work and the interesting
// failure — a strip whose frames could be shuffled — is visible in the
@@ -239,7 +306,7 @@ async function build() {
...row,
frames: undefined,
shots: await Promise.all((row.frames || []).map(async (f) => ({
time: f.time, kind: f.kind, scene: f.scene, act: f.act,
time: f.time, frame: f.frame, kind: f.kind, scene: f.scene, act: f.act,
tension: f.tension, journey: f.journey, moment: f.moment,
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
}))),
+45 -2
View File
@@ -44,6 +44,12 @@
.row.thin { border-color: #eab308; }
.row.good { border-color: #22c55e; }
.row.broken { border-color: #a855f7; background: #150e1c; }
.row.agitated { border-color: #f97316; }
.comfort { color: #9aa3b5; font-size: 11px; }
.comfort .meter i.low { background: #22c55e; }
.comfort .meter i.mid { background: #eab308; }
.comfort .meter i.high { background: #ef4444; }
.comfort .meter i.hot { background: #f97316; }
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.name { font-size: 14px; color: #e6eaf2; }
.fam { color: #6b7280; }
@@ -132,6 +138,7 @@
<option value="name">sort: by name</option>
<option value="family">sort: by family</option>
<option value="coverage">sort: sparsest first</option>
<option value="agitation">sort: most agitating first</option>
</select>
</span>
<span id="sweepctl" hidden>
@@ -155,8 +162,11 @@
<script type="module">
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.js';
import { AGITATION_CEILING } from '/src/engine/agitation.js';
// A gallery showing numbers the generator is not using is a trap, so say so.
const STALE_NOTE = metadataIsFresh() ? '' : ' · metadata is STALE — press refresh';
const AGITATION_CEILING_FALLBACK = 0.70;
const agitationCeiling = (typeof AGITATION_CEILING !== 'undefined' ? AGITATION_CEILING : AGITATION_CEILING_FALLBACK);
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
@@ -181,11 +191,13 @@ let contexts = [];
// the eye can still find the flattest scenes in a sorted list without anyone
// pretending a threshold decides whether a scene is allowed to exist.
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
const agitationGrade = (a) => (a >= agitationCeiling ? 'high' : a >= 0.50 ? 'mid' : 'low');
function draw() {
const mode = sortSel.value;
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? a.name.localeCompare(b.name)
mode === 'agitation' ? (b.agitation || 0) - (a.agitation || 0)
: mode === 'name' ? a.name.localeCompare(b.name)
: mode === 'coverage' ? (a.coverage || 0) - (b.coverage || 0)
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
: a.variety - b.variety));
@@ -193,9 +205,13 @@ function draw() {
out.innerHTML = '';
for (const row of sorted) {
const el = document.createElement('div');
el.className = `row ${grade(row.variety, row.error)}`;
el.className = `row ${grade(row.variety, row.error)}${(row.agitation || 0) >= agitationCeiling ? ' agitated' : ''}`;
const blocks = Object.entries(row.byBlock || {})
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
const agit = row.agitation || 0;
const flashPeak = row.flashPeak || 0;
const motionPeak = row.motionPeak || 0;
// structPeak present after refresh; falls back to 0 on cached rows
el.innerHTML = `
<div class="head">
<span class="name"><a href="?scene=${encodeURIComponent(row.name)}" title="open this one on a wide grid of songs and seeds">${row.name}</a></span>
@@ -205,6 +221,10 @@ function draw() {
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
${row.variety.toFixed(3)}
</span>
<span class="comfort" title="worst 1s window — max(flash/3, motion/0.32, struct/0.12). Whole-screen reserved for specials; region motion fine. Tiled rings / full pattern now count via struct. Ceiling ${agitationCeiling.toFixed(2)}">
<span class="meter"><i class="${agitationGrade(agit)}" style="width:${Math.min(100, agit * 100)}%"></i></span>
agit ${agit.toFixed(2)} · flash ${flashPeak.toFixed(1)}/s · motion ${(motionPeak * 100).toFixed(0)}% · struct ${(row.structPeak ?? 0).toFixed(2)} · <a class="metrics-toggle" href="#" onclick="event.preventDefault(); this.closest('.row').querySelector('.metrics-drawer').hidden = !this.closest('.row').querySelector('.metrics-drawer').hidden;">metrics ▸</a>
</span>
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
</div>
<div class="thumbs"></div>`;
@@ -222,6 +242,27 @@ function draw() {
// 390 images and decoding them all synchronously stalls the page.
blobToCanvas(canvas, blob);
});
const drawer = document.createElement('div');
drawer.className = 'metrics-drawer';
drawer.hidden = true;
drawer.style.cssText = 'font-size:10px;color:#8b94a7;margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center';
if (row.metrics) {
const order = ['agitation','structPeak','motionPeak','flashPeak','coverage','luminance','variance','drift','scaleEnergy','layoutEnergy','textureDensity'];
for (const k of order) {
const r = row.metrics[k];
if (!r) continue;
const chip = document.createElement('span');
chip.title = `${k}: mean ${r.mean.toFixed(3)} \u00b7 min ${r.min.toFixed(3)} \u00b7 max ${r.max.toFixed(3)} \u00b7 std ${r.std.toFixed(3)} — max drives filtering (some songs just won't use this scene)`;
chip.textContent = `${k} ${r.max.toFixed(3)}`;
const hot = (k === 'agitation' && r.max >= agitationCeiling) || (k === 'structPeak' && r.max > 0.16) || (k === 'motionPeak' && r.max > 0.38);
chip.style.cssText = `background:${hot ? '#3a1510' : '#1a1e28'};color:${hot ? '#f87171' : '#8b94a7'};padding:2px 6px;border-radius:3px;white-space:nowrap`;
drawer.appendChild(chip);
}
} else {
drawer.textContent = 'ranged metrics not yet measured \u2014 rebuild + Refresh metadata (SCHEMA 7)';
drawer.style.color = '#eab308';
}
el.appendChild(drawer);
out.appendChild(el);
}
}
@@ -238,6 +279,7 @@ function summarise(built) {
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
(broken ? ` · ${broken} BROKEN` : '') +
` · ${rows.filter((r) => (r.agitation || 0) >= agitationCeiling).length} agitated (≥${agitationCeiling.toFixed(2)})` +
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
}
@@ -271,6 +313,7 @@ async function build(fingerprint) {
role: row.module.role || 'stage', variety: row.variety,
coverage: row.coverage, surface: row.surface,
byBlock: row.byBlock, error: row.error,
agitation: row.agitation || 0, flashPeak: row.flashPeak || 0, motionPeak: row.motionPeak || 0, motionMean: row.motionMean || 0, structPeak: row.structPeak || 0, structMean: row.structMean || 0, metrics: row.metrics || null,
blobs: await Promise.all(row.thumbs.map(
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
});
+2
View File
@@ -59,6 +59,8 @@
<button id="btn-reroll" title="New seed for the whole track">reroll</button>
<button id="btn-reroll-section" title="New scene for this section only">reroll section</button>
<button id="btn-lock" title="Lock this section against rerolls">lock</button>
<button id="btn-llm-tweak" title="Let an LLM tune the generated song">✨ tune</button>
<button id="btn-llm-config" title="LLM gateway settings">⚙️</button>
<button id="btn-hud" title="Toggle debug HUD (D)">hud</button>
<button id="btn-osd" title="Toggle the title plate in the corner (O)">osd</button>
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
+142 -1
View File
@@ -9,14 +9,80 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@ai-sdk/openai": "^3.0.112",
"ai": "^6.0.282",
"mp4-muxer": "^5.2.2",
"three": "^0.181.1"
"three": "^0.181.1",
"zod": "^4.6.4"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"vite": "^7.2.2"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.193",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.193.tgz",
"integrity": "sha512-T2vq1EKPhbFF2Sk2MUFL/RmCg/2mp1LGbCZZSx5vh0M6Sr1EAj3wRr1EnpcwCV3/Imj2q56pT89XLiUFnB+ZgQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.16",
"@ai-sdk/provider-utils": "4.0.51",
"@vercel/oidc": "3.2.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.112",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.112.tgz",
"integrity": "sha512-0ho3a9CJ+3KCcrPdBQx8fX7/gyUxFHsfs1M6++SpunYk8+pgGOPMskr0GVOOxY+94jsJbuGxksKfMuSYhYHcxw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.16",
"@ai-sdk/provider-utils": "4.0.51"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.16.tgz",
"integrity": "sha512-9Av6kg0t/IN/dcYAAmEJ4B9OPhcEJqwSR+GfHEA8olRkindXpUHadc3p3cyvgFUQFTVm1G5thtsmgZ9yVb2w3A==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.51",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.51.tgz",
"integrity": "sha512-ukLTs9x1Xm6lxSIwbJIxYQxbZHmVeIczNntARZrBcOa6pjdBhqeZnATNnJMHa7M9dZ8Ji5NJbpKpvz7o5XyBtg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.16",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.8",
"undici": "^6.28.0"
},
"engines": {
"node": ">=18.17"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -476,6 +542,15 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
@@ -842,6 +917,12 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
@@ -861,6 +942,33 @@
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
"license": "MIT"
},
"node_modules/@vercel/oidc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/ai": {
"version": "6.0.282",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.282.tgz",
"integrity": "sha512-fdzEzSppVTYlgyW/Gp6tMAFIU88yv7k86x77m5vAJo7hXV31hDJCsAkq/aIpE3tP3k4LjrrLcUANPHcBZ0YWsw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.193",
"@ai-sdk/provider": "3.0.16",
"@ai-sdk/provider-utils": "4.0.51",
"@opentelemetry/api": "^1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -903,6 +1011,15 @@
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -936,6 +1053,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/mp4-muxer": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
@@ -1141,6 +1264,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/undici": {
"version": "6.28.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1215,6 +1347,15 @@
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.4.tgz",
"integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+4 -1
View File
@@ -20,7 +20,10 @@
"vite": "^7.2.2"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.112",
"ai": "^6.0.282",
"mp4-muxer": "^5.2.2",
"three": "^0.181.1"
"three": "^0.181.1",
"zod": "^4.6.4"
}
}
+113 -8
View File
@@ -7,6 +7,8 @@ import { ArcDriver } from './look/ArcDriver.js';
import { grainEnvelope } from './look/grain.js';
import { hashSamples } from './engine/rng.js';
import { subjectOf } from './look/stack.js';
import { pinVenueShell } from './venues/pin.js';
import { pinViz } from './look/pinViz.js';
/**
* The opening and closing fade to black. Exported because anything SAMPLING a
@@ -51,7 +53,7 @@ export class Show {
* only so that watching a song reproduces the look they measured — the
* filmstrip seeds off the song's name, not off its samples.
*/
async load(file, onProgress = null, { seed = null } = {}) {
async load(file, onProgress = null, { seed = null, pins = null } = {}) {
const report = (stage, p) => onProgress && onProgress(stage, p);
report('decoding', 0);
@@ -69,9 +71,18 @@ export class Show {
report('look', 0.97);
const samples = monoSamples(audioBuffer);
// A pinned venue shell implies the venue: ?venueSel= without ?venue=1
// still wants the room, not a silent skip.
const wantsVenue = this._wantsVenue() || !!(pins && pins.venueSel);
this.setLook(seed === null
? generateLook(this.track, { samples })
: generateLook(this.track, { seed }));
? generateLook(this.track, { samples, venueEnabled: wantsVenue })
: generateLook(this.track, { seed, venueEnabled: wantsVenue }));
// Preview deep-link pins (?venueSel=, ?viz=), applied here — after
// the look exists, before anything builds layers from it. Pinning
// later would need cache invalidation, and rebuilding mid-context
// renders differently than building once (measured). Gallery and
// filmstrip pin before useTrack for the same reason.
this.applyPins(pins);
this.engine.timeline.setDuration(this.track.duration);
this.engine.setFeatureProvider(featureProviderFor(this.track));
@@ -97,7 +108,7 @@ export class Show {
this.track = track;
this.engine.timeline.setDuration(track.duration);
this.engine.setFeatureProvider(featureProviderFor(track));
this.setLook(look || generateLook(track, { seed: 1 }));
this.setLook(look || generateLook(track, { seed: 1, venueEnabled: this._wantsVenue() }));
this.prewarm();
return this;
}
@@ -111,6 +122,34 @@ export class Show {
return this;
}
/**
* Preview deep-link pins: force the venue shell and/or the viz subject
* through the same shared paths the probe pages use, so a card and its
* link render the same look. Must run before the arc builds any layer
* (load() calls it between setLook and prewarm for exactly this reason).
* Unknown names warn and fall back to the census cast — never break a load.
*
* @returns {boolean} true when anything was pinned
*/
applyPins(pins) {
const { venueSel = null, viz = null } = pins || {};
if (!this.look || (!venueSel && !viz)) return false;
let pinned = false;
if (venueSel && this.look.venue) {
try {
pinned = pinVenueShell(this.look, venueSel,
{ seed: this.look.seed, buildIfMissing: true }) || pinned;
} catch (err) { console.warn('[flow-state]', err.message); }
}
if (viz) {
try {
pinViz(this.look, viz);
pinned = true;
} catch (err) { console.warn('[flow-state]', err.message); }
}
return pinned;
}
reroll(seed) {
this.setLook(rerollLook(this.look, this.track, seed));
this.prewarm();
@@ -144,6 +183,37 @@ export class Show {
this.engine.setSize(width, height);
}
_wantsVenue() {
try {
const qp = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
if (qp) {
const v = qp.get('venue');
if (v === '1' || v === 'true' || v === 'on') return true;
if (v && v !== '0' && v !== 'false' && v !== 'off') return true;
}
} catch { /* non-browser */ }
if (this._venueOverride !== undefined) return !!this._venueOverride;
return false;
}
setVenueEnabled(enabled) {
enabled = !!enabled;
this._venueOverride = enabled;
if (!this.track || !this.look) return;
const fromSamples = this.audioBuffer ? monoSamples(this.audioBuffer) : null;
const seed = this.look ? this.look.seed : 1;
try {
const next = fromSamples
? generateLook(this.track, { samples: fromSamples, venueEnabled: enabled, seed: null })
: generateLook(this.track, { seed, venueEnabled: enabled });
const locked = new Map();
for (const sec of this.look.sections) if (sec.locked) locked.set(sec.index, sec);
for (let i = 0; i < next.sections.length; i++) if (locked.has(i)) next.sections[i] = locked.get(i);
this.setLook(next);
this.prewarm();
} catch { /* leave look as-is on failure */ }
}
/** Fade in at the head and out at the tail; nothing starts or ends abruptly. */
_fadeAt(frame) {
const fadeFrames = FADE_SECONDS * this.fps;
@@ -208,16 +278,42 @@ export class Show {
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
const pers = this.look.personality;
const framing = this.arc.framingForFrame(timeline.frame);
const hasVenue = sceneLayers.some((l) => l && l.module && l.module.venue);
// The room gets its own framing, not the viz's: the venue camera
// relocates only on story jumps and otherwise holds, while the viz
// framing cuts at every shot. Feeding the viz framing to the room
// is what threw it around at every section. See _venueFramingAt.
// A venue that drives its own rig (directCamera) owns only its own
// camera: the shared rig keeps filming the viz subjects with the
// subject grammar and the viz's own framing. Sending a directCamera
// room down the venue path films the viz through a room camera
// grammar meant for another world — the subject vanishes. See
// Compositor._updateVenueCamera.
const venueForCamera = hasVenue && this.look.venue &&
!(this.look.venue.module && this.look.venue.module.directCamera)
? this.look.venue : null;
const framing = venueForCamera
? this.arc.venueFramingForFrame(timeline.frame)
: this.arc.framingForFrame(timeline.frame);
this.engine.compositor.updateSharedCamera({
framing,
personality: pers,
time: timeline.time,
venue: venueForCamera,
journey: this.arc.state ? this.arc.state.journey : 0,
// Continuous 0→1 across the video. The story journey travels in
// held stages (right for narrative, wrong for a camera dolly —
// staged values jump at section starts); progress is monotonic
// in the frame, so a traverse driven by it cannot jump.
progress: timeline.frameCount > 1
? timeline.frame / (timeline.frameCount - 1) : 0,
});
// Inject the shared camera into each active model layer so they render
// through one perspective and one depth buffer.
// through one perspective and one depth buffer. Skipped for layers
// whose module drives its own rig (directCamera) — injecting it
// would be dead weight, and its presence must never change output.
for (const l of sceneLayers) {
if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera;
if (l && l.module && l.module.kind === 'model' && !l.module.directCamera) l.sharedCamera = this.engine.compositor.sharedCamera;
}
}
@@ -283,8 +379,17 @@ export class Show {
* boundaries would make seeks exact at the cost of a visible flash at every
* transition, which is a much worse trade. Warm-up is cheap; the flash is not.
*/
/**
* Seek for review. `warmup` accepts true (converge per the feedback
* decay), false (reset, exact only without feedback), or an exact frame
* count — the form preview deep-links use to replay the probe's own
* warm-up length (?warm=), so both sides land on identically converged
* state. See src/ui/preview.js.
*/
seek(frame, { warmup = true } = {}) {
const frames = warmup ? this.warmupFrames() : 0;
const frames = typeof warmup === 'number'
? Math.max(0, Math.min(400, Math.round(warmup)))
: warmup ? this.warmupFrames() : 0;
if (frames > 0) {
this.warmUp(frame, frames);
} else {
+90 -1
View File
@@ -16,13 +16,102 @@ export async function decodeFile(file) {
return decodeArrayBuffer(arrayBuffer);
}
/**
* The rate everything is decoded at: the synth's rate (see audio/synth.js),
* never the hardware context rate.
*
* `AudioContext.decodeAudioData` resamples to the CONTEXT rate, so the same
* file came out 44.1k in one browser and 48k in another — and, worse, the
* probe pages (filmstrip/gallery/venue-gallery) all render from 44.1k synth
* while the app decoded the bank wav at the context rate. Different spectra,
* different features, different videos: no preview deep-link could ever
* reproduce its image. Decoding in a fixed-rate OfflineAudioContext makes
* the app see the same audio the probes do. See src/ui/preview.js.
*/
export const DECODE_RATE = 44100;
export async function decodeArrayBuffer(arrayBuffer) {
const ctx = context();
// Our own files first, sample-exact: even at a 1.0 ratio Chrome's
// decodeAudioData perturbs int16→float conversion (measured 2.6e-5),
// which is enough to flip section classification on quiet tracks and
// break every preview deep-link. A manual parse keeps every bit.
const wav = decodeWav(arrayBuffer);
if (wav) return wav;
const ctx = new OfflineAudioContext(2, DECODE_RATE, DECODE_RATE);
// decodeAudioData detaches the buffer, so hand it a copy if the caller may
// still need the original bytes.
return await ctx.decodeAudioData(arrayBuffer.slice(0));
}
/**
* Exact WAV decode: 8/16/24-bit PCM and 32-bit float, any rate, any
* channels. No resampling, no conversion noise — int16 comes back as
* V / 32768, the same floats the bank PCM holds. Returns null for
* anything else (compressed formats still go through decodeAudioData).
*/
export function decodeWav(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const dv = new DataView(arrayBuffer);
const str = (o, n) => {
let s = '';
for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]);
return s;
};
if (bytes.length < 44 || str(0, 4) !== 'RIFF' || str(8, 4) !== 'WAVE') return null;
let fmt = null, dataOff = 0, dataLen = 0;
let o = 12;
while (o + 8 <= bytes.length) {
const id = str(o, 4);
const size = dv.getUint32(o + 4, true);
if (id === 'fmt ') {
fmt = {
audioFormat: dv.getUint16(o + 8, true),
channels: dv.getUint16(o + 10, true),
sampleRate: dv.getUint32(o + 12, true),
bits: dv.getUint16(o + 22, true),
};
} else if (id === 'data') {
dataOff = o + 8;
dataLen = Math.min(size, bytes.length - dataOff);
}
o += 8 + size + (size % 2);
}
if (!fmt || !dataLen || (fmt.audioFormat !== 1 && fmt.audioFormat !== 3)) return null;
if (fmt.channels < 1 || fmt.channels > 32 || !fmt.sampleRate) return null;
const bytesPerSample = fmt.audioFormat === 3 ? 4 : fmt.bits / 8;
if (![1, 2, 3, 4].includes(bytesPerSample) || (fmt.audioFormat === 3 && fmt.bits !== 32)) return null;
const frames = Math.floor(dataLen / (bytesPerSample * fmt.channels));
if (frames < 1) return null;
let out;
try {
out = new AudioBuffer({
length: frames, sampleRate: fmt.sampleRate, numberOfChannels: fmt.channels,
});
} catch { return null; }
for (let c = 0; c < fmt.channels; c++) {
const ch = out.getChannelData(c);
for (let i = 0; i < frames; i++) {
const at = dataOff + (i * fmt.channels + c) * bytesPerSample;
let v;
if (fmt.audioFormat === 3) {
v = dv.getFloat32(at, true);
} else if (bytesPerSample === 1) {
v = (bytes[at] - 128) / 128;
} else if (bytesPerSample === 2) {
v = dv.getInt16(at, true) / 32768;
} else if (bytesPerSample === 3) {
let s = bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
if (s & 0x800000) s -= 0x1000000;
v = s / 8388608;
} else {
v = dv.getInt32(at, true) / 2147483648;
}
ch[i] = v;
}
}
return out;
}
/** Interleaved mono mixdown. Used for the content hash that seeds the look. */
export function monoSamples(audioBuffer) {
const channels = audioBuffer.numberOfChannels;
+33 -6
View File
@@ -26,7 +26,30 @@
// These are test signals, not music. They exist to be measured.
import { FeatureTrack } from './FeatureTrack.js';
import { synthesizeSong } from './synth.js';
import { synthesizeSong, MockAudioBuffer } from './synth.js';
/**
* The bank's PCM contract: peak-normalise to -1 dBFS, then round to the
* 16-bit grid — bit-exactly what tools/build-song-bank.js writes into
* test/songs/*.wav (int16 LE of round(v * 32767), read back as V / 32768).
*
* Why this exists: the app decodes the wav while every probe page analyses
* the synth, and unless both see these exact samples their features differ
* (measured: 3x loudness and a whole extra drop section on drone) and no
* preview deep-link can reproduce its image. song() applies it so the bank
* IS the files; the writer shares it so the files stay the bank.
*/
export function bankPcm(samples) {
let peak = 0;
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
const gain = peak > 1e-9 ? 0.89 / peak : 1;
const out = new Float32Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const v = Math.max(-1, Math.min(1, samples[i] * gain));
out[i] = Math.round(v * 32767) / 32768;
}
return out;
}
/**
* @typedef {object} SongSpec
@@ -88,9 +111,10 @@ export function song(name, { fps = 60, duration = DURATION } = {}) {
const spec = SONGS.find((s) => s.name === name);
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
const entry = {
...spec,
track: FeatureTrack.fromAudioBuffer(synthesizeSong({
// Analysed from the bank PCM, not the raw synth: peak gain, 16-bit
// grid, mono — the exact samples test/songs/*.wav holds, so this track
// is the file the app decodes rather than a near-twin of it. See bankPcm.
const synth = synthesizeSong({
bpm: spec.bpm,
duration,
arrangement: spec.arrangement,
@@ -100,8 +124,11 @@ export function song(name, { fps = 60, duration = DURATION } = {}) {
dynamics: spec.dynamics,
transition: spec.transition ?? 0.5,
seed: 1 + SONGS.indexOf(spec),
}), { fps }),
};
});
const mono = new MockAudioBuffer(
1, synth.length, synth.sampleRate);
mono.getChannelData(0).set(bankPcm(synth.getChannelData(0)));
const entry = { ...spec, track: FeatureTrack.fromAudioBuffer(mono, { fps }) };
cache.set(cacheKey, entry);
return entry;
}
+69 -6
View File
@@ -24,9 +24,12 @@ import { Show, FADE_SECONDS } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
import { song, SONGS } from '../audio/songbank.js';
import { hashString } from '../engine/rng.js';
import { pinVenueShell } from '../venues/pin.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
import { describeStory } from '../look/Story.js';
import { frameLuminance } from '../engine/hash.js';
import { agitationScore, AGITATION_CEILING } from '../engine/agitation.js';
export const STRIP = { width: 256, height: 144 };
@@ -51,7 +54,9 @@ export const DEFAULT_DURATION = 180;
* the whole strip look more alike than it is. Half of what the variety harness
* uses, because this is 17 songs deep and the cost is linear.
*/
const WARMUP = 24;
/** Frames of warm-up behind every probe. Carried in preview links (?warm=)
* so the app lands on identically converged state. See src/ui/preview.js. */
export const WARMUP = 24;
export const songNames = () => SONGS.map((s) => s.name);
@@ -67,12 +72,26 @@ function blockMean(byBlock) {
*
* @returns {{name, frames: Array, drift: number, direction: number, look}}
*/
export function renderSong(show, name, { duration, every }) {
export function renderSong(show, name, { duration, every, venueEnabled = null, venueSel = null }) {
const { track } = song(name, { duration });
// Seeded off the song's name, so a strip is stable between rebuilds and two
// songs are not accidentally handed the same look.
const look = generateLook(track, { seed: hashString(name) });
const venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
// Pin one venue in every row (same shell-swap as the venue gallery): the
// VenueSpec population stays the census one, only the shell is forced.
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
if (sel) pinVenueShell(look, sel);
// The file name the app shows in its title plate (OSD): without it the
// probe renders a blank plate and no deep-link can match the app's
// default view. See src/ui/preview.js.
show.fileName = name;
show.useTrack(track, look);
// Prime once per song: programs link asynchronously and a draw against an
// unlinked program is wrong output — invisible in a sweep, wrong in an
// export, and fatal to preview deep-links, which replay these frames in
// the app (primed there too). See src/ui/preview.js.
show.prime();
const frames = [];
const descriptors = [];
@@ -126,11 +145,54 @@ export function renderSong(show, name, { duration, every }) {
}
}
// Agitation: dense 60 fps burst in the busiest section, so the filmstrip is
// also the agitation audit. The 30 s probes are 30 s apart and cannot see
// beat-locked whole-screen motion — the metric needs consecutive frames.
// A 120-frame (2 s) burst at the drop/busiest section captures the worst
// 1 s floating window at its most energetic, mirroring gallery.js which
// samples its 60-frame strip at the busiest context. Whole-screen
// flash/coherent motion reserved for special cases, region motion fine.
let score = { agitation: 0, peakFlashRate: 0, peakMotion: 0, meanMotion: 0, peakStructure: 0, meanStructure: 0 };
let agitated = false;
try {
const busiest = look.sections.reduce(
(best, s) => ((s.bias?.energy ?? 0) > (best.bias?.energy ?? 0) ? s : best),
look.sections[0] || { startFrame: 0, endFrame: Math.min(track.frameCount, 120) },
);
const probe = busiest.startFrame + Math.floor((busiest.endFrame - busiest.startFrame) * 0.5);
const STRIP_AGIT = 120; // 2 s @ 60 fps → 1 s window worst
show.warmUp(probe, WARMUP);
const agFrames = [];
const agLums = [];
for (let f = probe; f < probe + STRIP_AGIT; f++) {
const ff = Math.min(track.frameCount - 1, f);
const px = Uint8Array.from(show.readPixels(show.renderFrame(ff)));
agFrames.push(px);
agLums.push(frameLuminance(px));
}
score = agitationScore(agLums, agFrames, 60, { width: STRIP.width, height: STRIP.height });
agitated = score.agitation >= AGITATION_CEILING;
} catch {
// leave agitation at 0 — row still renders, just grey
}
return {
name,
frames,
look,
drift: mean(dists),
direction: directionOf(gaps, dists),
// Agitation over the strip: worst 1 s floating window, max(flash/3,
// motion/0.55) → 0..1. The filmstrip already renders the whole video,
// so this is the natural audit for "how much of the view moves/flashes
// together". Gallery's agitation is per-scene; this one is per-song.
agitation: score.agitation,
agitationFlash: score.peakFlashRate,
agitationMotion: score.peakMotion,
agitationMean: score.meanMotion,
agitationStructPeak: score.peakStructure,
agitationStruct: score.meanStructure,
agitated,
// How much the FIRST half of the video resembles the last — the reading
// that separates a video which ends somewhere new from one that has
// come back on purpose. A recap should show up here and nowhere else.
@@ -161,7 +223,8 @@ function momentAt(story, index) {
* times for nothing.
*/
export async function buildFilmstrip({
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null,
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, venueEnabled = null,
venueSel = null,
onSong = null, onSongStart = null,
} = {}) {
const list = names && names.length ? names : songNames();
@@ -179,9 +242,9 @@ export async function buildFilmstrip({
}
let row;
try {
row = renderSong(show, list[i], { duration, every });
row = renderSong(show, list[i], { duration, every, venueEnabled, venueSel });
} catch (err) {
row = { name: list[i], frames: [], drift: 0, direction: 0, error: err.message };
row = { name: list[i], frames: [], shots: [], drift: 0, direction: 0, error: err.message };
}
rows.push(row);
if (onSong) onSong(i + 1, list.length, row);
+111
View File
@@ -28,6 +28,8 @@ import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { frameLuminance, frameVariance } from '../engine/hash.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { generateActor } from '../actors/ActorGenerator.js';
import { agitationScore } from '../engine/agitation.js';
import { probesForContext, ranged, METRIC_KEYS } from './scene-metrics.js';
const THUMB = { width: 256, height: 144 };
@@ -162,6 +164,108 @@ export function renderScene(engine, module, contexts) {
const variance = frameVariance(thumbs[0]);
const dead = lum < 0.002 || variance < 0.001;
// --- per-visualizer 12+ ranged metrics + legacy flat agitation --------
// Legacy flat agitation/flashPeak/motionPeak/structPeak: busiest context
// only (cheapest, what existing gallery.html reads). Ranged metrics:
// 6 contexts × 90-frame strip each → {mean,min,max,std,p10,p90} per key
// (see src/checks/scene-metrics.js). Vast preliminary library the director
// filters BEFORE casting; some songs simply won't use certain scenes.
let agitation = 0;
let flashPeak = 0;
let motionPeak = 0;
let motionMean = 0;
let structPeak = 0;
let structMean = 0;
let metrics = null;
if (!dead && contexts && contexts.length) {
try {
// Busiest context = most energetic bias — legacy flat value.
let probe = contexts[0];
for (const c of contexts) {
if ((c.bias.energy || 0) > (probe.bias.energy || 0)) probe = c;
}
engine.timeline.setDuration(probe.track.duration);
engine.setFeatureProvider(featureProviderFor(probe.track));
const arng = new Rng(hashString(`${module.name}:agitation:${probe.name}`));
const aparams = sampleValues(module, arng, probe.bias, probe.personality.temperament);
const aactorRng = new Rng(arng.fork(`actor:${module.actor || 'model'}`).seed);
const aactorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: probe.track.summary, rng: aactorRng, archetype: module.actor, personality: probe.personality, identity: probe.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: aparams,
seed: arng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: probe.palette,
personality: probe.personality,
actorSpec: aactorSpec,
}]);
engine.compositor.reset();
for (let f = probe.frame - 6; f < probe.frame; f++) engine.renderFrame(f);
const STRIP = 90; // 1.5 s @ 60 fps — catches Voronoi recut between phrase windows
const stripFrames = [];
const luminances = [];
for (let f = probe.frame; f < probe.frame + STRIP; f++) {
const pix = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
stripFrames.push(pix);
luminances.push(frameLuminance(pix));
}
const score = agitationScore(luminances, stripFrames, 60, { width: THUMB.width, height: THUMB.height });
agitation = score.agitation;
flashPeak = score.peakFlashRate;
motionPeak = score.peakMotion;
motionMean = score.meanMotion;
structPeak = score.peakStructure;
structMean = score.meanStructure;
} catch {
// leave agitation at 0 — gallery will show 0.000 rather than crash
}
// Ranged metrics across all 6 contexts (one 90-frame strip each, just once)
try {
const perContext = [];
for (const ctx of contexts) {
engine.timeline.setDuration(ctx.track.duration);
engine.setFeatureProvider(featureProviderFor(ctx.track));
const crng = new Rng(hashString(`${module.name}:metrics:${ctx.name}`));
const cparams = sampleValues(module, crng, ctx.bias, ctx.personality.temperament);
const cActorRng = new Rng(crng.fork(`actor:${module.actor || 'model'}`).seed);
const cActorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: ctx.track.summary, rng: cActorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: cparams,
seed: crng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: ctx.palette,
personality: ctx.personality,
actorSpec: cActorSpec,
}]);
engine.compositor.reset();
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
const MSTRIP = 90;
const mFrames = [];
const mLums = [];
for (let f = ctx.frame; f < ctx.frame + MSTRIP; f++) {
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
mFrames.push(px);
mLums.push(frameLuminance(px));
}
perContext.push(probesForContext({ luminances: mLums, stripFrames: mFrames, width: THUMB.width, height: THUMB.height }));
}
metrics = {};
for (const key of METRIC_KEYS) {
const vals = perContext.map((p) => p[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
metrics[key] = ranged(vals);
}
} catch {
// metrics stays null — gallery shows legacy flat values
}
}
return {
thumbs,
// The six per-context descriptors, so a caller can average them into a
@@ -173,6 +277,13 @@ export function renderScene(engine, module, contexts) {
variety: pairs ? total / pairs : 0,
coverage: covered,
byBlock,
agitation,
flashPeak,
motionPeak,
motionMean,
structPeak,
structMean,
metrics,
error: dead
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
'A failed shader compile scores 0.000 on every block; check the console for GLSL errors.'
+40 -2
View File
@@ -25,6 +25,7 @@ import { galleryContexts, renderScene, THUMB } from './gallery.js';
import { readsHistory } from '../params/schema.js';
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
import metadata from '../scenes/metadata.json';
import { METRIC_KEYS } from './scene-metrics.js';
/**
* What invalidates the measurements.
@@ -45,8 +46,16 @@ const SOURCES = {
...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/scene-metrics.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/agitation.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/flash.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/Layer.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/comfort.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/scene-filter.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/composition-validator.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/scenes/surface.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
@@ -55,7 +64,7 @@ const SOURCES = {
};
/** The version of the measurement itself. Bump to force a refresh of everything. */
export const SCHEMA = 5;
export const SCHEMA = 7;
export function metricsFingerprint() {
let h = 0x811c9dc5 >>> 0;
@@ -133,8 +142,18 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
try {
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
for (const module of list) {
const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
const { thumbs, variety, byBlock, descriptors, error, agitation, flashPeak, motionPeak, motionMean, structPeak, structMean, metrics } = renderScene(engine, module, ctx);
const bed = renderScene(engine, module, bedCtx);
// Round ranged metrics to 3dp (4dp for agitation trio) — small, stable diffs
const roundedMetrics = {};
if (metrics) {
for (const [k, r] of Object.entries(metrics)) {
roundedMetrics[k] = {
mean: round(r.mean, 3), min: round(r.min, 3), max: round(r.max, 3),
std: round(r.std, 3), p10: round(r.p10, 3), p90: round(r.p90, 3),
};
}
}
out[module.name] = {
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
// The WORST of the six, not the mean. What a ground has to
@@ -151,6 +170,16 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
variety: round(variety, 3),
blocks: Object.fromEntries(
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
// Legacy flat (busiest-context) — compat with existing comfort.js:agitationOf
agitation: round(agitation || 0, 3),
flashPeak: round(flashPeak || 0, 2),
motionPeak: round(motionPeak || 0, 3),
motionMean: round(motionMean || 0, 3),
structPeak: round(structPeak || 0, 3),
structMean: round(structMean || 0, 3),
// Ranged metrics — 12+ keys × {mean,min,max,std,p10,p90}
// Vast preliminary library the director filters before casting
metrics: roundedMetrics,
// Declared, not measured, and carried here anyway: it is a fact
// about the scene that the composition rules read, and having
// every compositional input in one file is the point.
@@ -193,6 +222,15 @@ export function metadataDrift(fresh) {
delta,
});
}
const aNew = row.agitation ?? 0;
const aWas = was.agitation ?? 0;
const aDelta = aNew - aWas;
if (Math.abs(aDelta) > 0.08) {
moved.push({
name, note: `agitation ${aWas.toFixed(2)} → ${aNew.toFixed(2)}`,
delta: aDelta,
});
}
}
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
return {
+18 -1
View File
@@ -21,7 +21,7 @@ import { shiftPalette, paletteContrast } from '../look/palette.js';
import { ArcDriver } from '../look/ArcDriver.js';
import { Engine } from '../engine/Engine.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { sampleValues, clampValue, canBackground } from '../params/schema.js';
import { sampleValues, clampValue, canBackground, isDiscreteParam } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
@@ -505,6 +505,23 @@ check(11, 'the slow axis is a journey rather than a cycle', () => {
const end = at(t.duration * 0.95);
for (const item of axis) {
// Discrete params step at cuts instead of travelling: held within
// the shot by construction (see schema.js isDiscreteParam), so the
// travel assertion below cannot apply. What is asserted instead is
// the hold itself — two samples inside one cue must agree exactly —
// while the travel magnitude is already covered structurally above.
if (isDiscreteParam(item.def)) {
const t0 = (cue.startFrame + 5) / 60;
const t1 = (cue.endFrame - 5) / 60;
if (t1 > t0) {
const va = at(t0)[item.name], vb = at(t1)[item.name];
if (va !== vb) {
problems.push(`${spec.module.name}.${item.name}: ${va} → ${vb} ` +
`mid-shot — discrete params change only at cuts`);
}
}
continue;
}
const [lo, hi] = item.def.range;
const span = hi - lo;
const a = start[item.name], m = mid[item.name], z = end[item.name];
+23
View File
@@ -22,6 +22,7 @@ import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
import { peakFlashRate } from '../engine/flash.js';
import { AGITATION_CEILING, agitationScore } from '../engine/agitation.js';
import { generatePersonality } from '../look/Personality.js';
import { generateIdentity } from '../look/Identity.js';
import { generateActor } from '../actors/ActorGenerator.js';
@@ -154,6 +155,28 @@ export function runSceneGate(name) {
const rate = peakFlashRate(luminance, 60);
record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`);
// --- comfort / agitation -----------------------------------------
// Whole-screen flash + coherent motion as a single 0..1 score over a
// 1 s floating window. Mirrors the gallery's agitation measurement but
// sampled at the hot bias (energy/density/motion high) — the worst this
// scene is allowed to be when the track asks for it. Region-based motion
// (a ribbon in the centre) stays low; full-screen translation/zoom and
// uniform luminance pumping score high. See engine/agitation.js.
{
// Reuse the 300-frame luminance series above plus a frame strip for motion.
// Motion needs actual pixels; keep engine sized at the gate's 192x108.
const W = engine.width, H = engine.height;
const frames = [];
for (let f = 600; f < 900; f++) {
frames.push(Uint8Array.from(engine.readPixels(engine.renderFrame(f))));
}
const score = agitationScore(luminance, frames, 60, { width: W, height: H });
const detail = `agitation ${score.agitation.toFixed(2)} ` +
`(flash ${score.peakFlashRate}/s, motion ${(score.peakMotion * 100).toFixed(0)}% tiles, struct ${score.peakStructure.toFixed(2)}) ` +
`ceiling ${AGITATION_CEILING.toFixed(2)}`;
record(score.agitation <= AGITATION_CEILING, 'comfort', detail);
}
// --- identity response -----------------------------------------------
// The migration gate. A scene that declares it consumes the cast must
// produce a DIFFERENT PICTURE when the song's cast changes — otherwise
+109
View File
@@ -0,0 +1,109 @@
// Per-scene metric collection — the 12+ ranged metrics the director filters on.
//
// Extracted from gallery.js so both the gallery and the scene gate share the
// strip measurement (agitation + structure + coverage/brightness). gallery.js keeps
// the 90-frame busiest-context strip; scene-metrics.js knows how to reduce 6 such
// probe values into ranged stats {mean,min,max,std,p10,p90} and how to define each
// metric's read. Measured once per scene (6 contexts × one strip), not per bias.
import { frameVariance } from '../engine/hash.js';
import { agitationScore } from '../engine/agitation.js';
import { frameDescriptor } from './variety/descriptors.js';
import { descriptorDistance } from './variety/signature.js';
/** Fraction lit > 90, 0..1. */
export function litFraction(pixels) {
let lit = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
}
return lit / (pixels.length / 4);
}
/**
* Ranged stats over an array of numbers.
* @returns {{mean:number, min:number, max:number, std:number, p10:number, p90:number}}
*/
export function ranged(values) {
if (!values || !values.length) return { mean: 0, min: 0, max: 0, std: 0, p10: 0, p90: 0 };
const sorted = [...values].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
const pAt = (p) => {
const at = (n - 1) * p;
const lo = Math.floor(at), hi = Math.ceil(at);
return lo === hi ? sorted[lo] : sorted[lo] * (1 - (at - lo)) + sorted[hi] * (at - lo);
};
return {
mean, min: sorted[0], max: sorted[n - 1],
std: Math.sqrt(variance),
p10: pAt(0.10), p90: pAt(0.90),
};
}
function driftFromFrames(frames) {
if (!frames || frames.length < 2) return 0;
let sum = 0, n = 0;
for (let i = 1; i < frames.length; i++) {
try {
const w = frames[i].width || 256, h = frames[i].height || 144;
const a = frames[i - 1].pixels || frames[i - 1];
const b = frames[i].pixels || frames[i];
const da = frameDescriptor(a, w, h);
const db = frameDescriptor(b, w, h);
const d = descriptorDistance(da, db);
let s = 0, c = 0;
for (const k of ['scale', 'orient', 'layout', 'region', 'texture']) {
if (d[k] !== undefined) { s += d[k]; c++; }
}
sum += c ? s / c : 0;
n++;
} catch { /* ignore one bad pair */ }
}
return n ? sum / n : 0;
}
/**
* Per-context numeric probes (single strip at that context). Call 6× and ranged() each key.
* Returns one number per metric for this context.
*/
export function probesForContext({ luminances, stripFrames, width, height }) {
const score = agitationScore(luminances, stripFrames, 60, { width, height });
const lums = luminances;
const meanLum = lums.length ? lums.reduce((a, b) => a + b, 0) / lums.length : 0;
let meanVar = 0;
for (let i = 0; i < stripFrames.length; i++) {
try { meanVar += frameVariance(stripFrames[i]); } catch { /* ignore */ }
}
meanVar = stripFrames.length ? meanVar / stripFrames.length : 0;
let scaleEnergy = 0, layoutEnergy = 0, textureDensity = 0;
try {
const d = frameDescriptor(stripFrames[0], width, height);
if (d.scale && d.scale.length) scaleEnergy = d.scale.reduce((a, b) => a + b, 0) / d.scale.length;
if (d.layout && d.layout.length) layoutEnergy = d.layout.reduce((a, b) => a + Math.abs(b), 0) / d.layout.length;
if (d.texture && d.texture.length >= 2) textureDensity = (d.texture[0] + d.texture[1]) / 2;
} catch { /* keep 0 */ }
const drift = driftFromFrames(stripFrames.map((p) => ({ pixels: p, width, height })));
const cov = stripFrames.length ? litFraction(stripFrames[0]) : 0;
return {
coverage: cov,
luminance: meanLum,
variance: meanVar,
flashPeak: score.peakFlashRate,
motionPeak: score.peakMotion,
structPeak: score.peakStructure,
agitation: score.agitation,
drift,
scaleEnergy,
layoutEnergy,
textureDensity,
};
}
// Keys that participate in ranged stats (12 incl. costHint handled separately)
export const METRIC_KEYS = [
'coverage', 'luminance', 'variance',
'flashPeak', 'motionPeak', 'structPeak', 'agitation',
'drift', 'scaleEnergy', 'layoutEnergy', 'textureDensity',
];
+166
View File
@@ -0,0 +1,166 @@
// The vis testing battery as a library.
//
// Every manual testing entry point this project has — bank songs, scenes by
// family, venues, param/scenario combos, the throwaway lab pages and the
// headless CLI runners — collected in one place so debug.html (and anything
// headless later) renders them from the live registries instead of a stale
// hardcoded list. Adding a song, scene or venue updates the battery for free.
//
// Pure URL builders + data: no DOM, no WebGL, no wall-clock. Safe to import
// anywhere, including node (nothing here touches metadata.json, which needs
// Vite's JSON handling — so scene *coverage* is deliberately not imported).
import { SONGS } from '../audio/songbank.js';
import { scenes } from '../scenes/registry.js';
import { venues } from '../venues/registry.js';
const q = (params) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === null || v === undefined || v === '') continue;
p.set(k, String(v));
}
const s = p.toString();
return s ? `?${s}` : '';
};
export const songNames = () => SONGS.map((s) => s.name);
export const songCovers = (name) =>
(SONGS.find((s) => s.name === name) || {}).covers || '';
export const sceneNames = () => scenes.map((s) => s.name);
export const venueNames = () => venues.map((v) => v.name);
export const families = () => {
const out = [];
for (const s of scenes) if (!out.includes(s.family)) out.push(s.family);
return out;
};
export const scenesIn = (family) =>
scenes.filter((s) => s.family === family).map((s) => s.name);
/** App links for a bank song: plain, with venue, pinned seeds. */
export const appLinks = (song) => ({
app: `/${q({ song })}`,
venue: `/${q({ song, venue: 1 })}`,
seed1: `/${q({ song, seed: 1 })}`,
seed2: `/${q({ song, seed: 2 })}`,
});
/** Per-scene links: acceptance battery + wide single-scene gallery. */
export const sceneLinks = (name) => ({
gate: `/checks.html${q({ scene: name })}`,
wide: `/gallery.html${q({ scene: name, songs: 6, seeds: 6 })}`,
});
/** Per-venue links: gallery pinned to the venue, app + filmstrip with venue. */
export const venueLinks = (name) => ({
gallery: `/venue-gallery.html${q({ venueSel: name })}`,
app: `/${q({ song: 'centre', venue: 1 })}`,
filmstrip: `/filmstrip.html${q({ song: 'centre,ember', venue: 1, venueSel: name })}`,
});
/**
* Curated param/scenario combos: each is one question with the links that
* answer it. Songs are corners of the feature space (see songbank.js
* `covers`); pairs differ in exactly one control where possible.
*/
export const SCENARIOS = [
{
title: 'venue A/B, same song',
desc: 'Does the room earn its place? Same seed both sides — only the venue differs.',
links: [
['no venue', `/${q({ song: 'centre', seed: 7 })}`],
['venue', `/${q({ song: 'centre', seed: 7, venue: 1 })}`],
],
},
{
title: 'seed reroll, same song',
desc: 'Same music, two draws. Should read as two videos, not two grades of one.',
links: [
['seed 1', `/${q({ song: 'ember', seed: 1 })}`],
['seed 2', `/${q({ song: 'ember', seed: 2 })}`],
],
},
{
title: 'tempo ends',
desc: '62bpm beatless vs 174bpm broken. Motion should follow the song, not the scene.',
links: [
['drone 62', `/${q({ song: 'drone' })}`],
['runner 174', `/${q({ song: 'runner' })}`],
],
},
{
title: 'degenerate case',
desc: 'Two sections. Must not break — still is the regression test for it.',
links: [['still', `/${q({ song: 'still' })}`]],
},
{
title: 'hostile cases',
desc: 'Limitered, noisiest, brightest: the corners that collapse palettes and directors.',
links: [
['slab (flat)', `/${q({ song: 'slab' })}`],
['grit (noise)', `/${q({ song: 'grit' })}`],
['flare (bright)', `/${q({ song: 'flare' })}`],
],
},
{
title: 'most sections',
desc: 'Lattice rotates rosters the hardest. Watch for A/B ping-ponging and repeats.',
links: [['lattice', `/${q({ song: 'lattice' })}`]],
},
{
title: 'venue × viz pins',
desc: 'One room, chosen viz in it: fragment viz play on the room\u2019s screen, model viz stand in the shared world. Combos picked so the viz actually reads — a dark or sparse viz on a small screen is invisible, which is how a dead pin hides. (Ride is excluded: its viz lives on the dash radio, too small to review here.)',
links: [
['Chamber + Kaleido', `/venue-gallery.html${q({ venueSel: 'Chamber', viz: 'Kaleido Tunnel', rows: 2, cols: 3 })}`],
['Stage + Assembly', `/venue-gallery.html${q({ venueSel: 'Stage', viz: 'Assembly', rows: 2, cols: 3 })}`],
],
},
{
title: 'filmstrip with venue',
desc: 'Does the room hold for two minutes, or does it drift, flash, or empty out?',
links: [
['centre + ember', `/filmstrip.html${q({ song: 'centre,ember', venue: 1 })}`],
],
},
{
title: 'fresh scene gates',
desc: 'The per-scene battery for the scenes most recently touched.',
links: [
['Swarm', `/checks.html${q({ scene: 'Swarm' })}`],
['Assembly', `/checks.html${q({ scene: 'Assembly' })}`],
['Pylon Field 3D', `/checks.html${q({ scene: 'Pylon Field 3D' })}`],
],
},
];
/**
* The graduated throwaways: single-purpose lab pages plus the headless
* runners that drive them. Linked, not duplicated — the pages stay where
* the runners expect them.
*/
export const LABS = [
{
title: 'ride lab',
href: '/ride-lab.html',
desc: 'One Ride, four inspection cameras. Settles which way the car faces.',
cli: 'node ride-lab-run.mjs',
},
{
title: 'passerby lab',
href: '/passerby-lab.html',
desc: 'Hunts a seed with a passerby cue on elegy, renders the shot head/mid/tail.',
cli: null,
},
{
title: 'ride closeup',
href: '/venue-gallery.html?rows=1&cols=1&venueSel=Ride',
desc: 'Wheels + beams at 2×. Same framing the closeup runner screenshots.',
cli: 'node ride-closeup.mjs',
},
{
title: 'venue gate loop',
href: null,
desc: 'Headless Chromium against vite dev: phases, filmstrip, gallery, variety. Screenshots to logs/venue/.',
cli: 'node tools/venue-check.js --quick',
},
];
+259 -5
View File
@@ -64,6 +64,12 @@ export class Compositor {
const bh = Math.max(1, Math.floor(h / 2));
this.layerTarget = r.createTarget(w, h, { depth: true });
// Viz render target for the venue RTT path: non-venue layers composite
// here first, then the texture is bound onto the venue's screen and the
// venue itself renders into layerTarget. Same size as the layer target
// (aspect-preserving screens sample it; oversized is harmless, undersized
// would smear text). Cleared with the rest in reset().
this.vizTarget = r.createTarget(w, h, { depth: true });
this.accumA = r.createTarget(w, h);
this.accumB = r.createTarget(w, h);
this.historyA = r.createTarget(w, h, { float: true });
@@ -89,10 +95,25 @@ export class Compositor {
this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height);
}
/** Drive the shared perspective rig from the current framing + gaze + personality. */
updateSharedCamera({ framing, personality, time }) {
/**
* Drive the shared perspective rig from the current framing + gaze + personality.
*
* `venue` selects the grammar. Without one the camera is the old subject
* rig: near-still at dolly ~4, drift measured in hundredths, because that
* camera films an object. A venue is a room the camera is INSIDE, and the
* same hundredths read as a locked tripod in the middle of it — which is
* why every venue render so far has been one static wide. The room grammar
* walks: a slow lateral travel (±1.5) with a counter-pan so the screen stays
* framed, a per-section push-in from the story journey, and a handheld
* layer from churn. All analytic f(time, seed) — seek === playback.
*/
updateSharedCamera({ framing, personality, time, venue = null, journey = 0 }) {
const cam = this.sharedCamera;
const frame = framing || { scale: 1, shift: [0, 0] };
if (venue) {
this._updateVenueCamera(cam, { framing: frame, personality, time, venue, journey });
return;
}
const dolly = 4 / Math.max(frame.scale, 0.05);
const pCam = personality ? personality.camera : null;
if (pCam) {
@@ -114,6 +135,156 @@ export class Compositor {
cam.updateMatrixWorld();
}
_updateVenueCamera(cam, { framing, personality, time, venue, journey, progress = null }) {
const pCam = personality ? personality.camera : null;
const motion = personality ? personality.motion : null;
const seed = (venue.seed >>> 0) || 1;
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
// Smaller rooms get a smaller walk: chamber is 22 wide, stage 44.
// (Ride drives its own rig now — see ride.directCamera — and never
// reaches this path, but the table stays total over archetypes.)
const room = venue.archetype === 'chamber' ? 0.62 : 1.0;
const still = motion ? motion.stillness : 0.5;
const churn = motion ? motion.churn : 0.25;
// Planned room shots (see look/venueShots.js): absolute per-cue marks
// with a motive — screen, furniture, or the whole room. When the arc
// serves one, it replaces the traverse/push/gaze stack below outright;
// only the handheld texture survives. No plan (hand-built looks, old
// tests driving this method directly) → the legacy traverse path.
const planned = framing && framing.venueShot ? framing.venueShot : null;
// The walk: a TRAVERSE, not a sway. Two oscillators were tried here and
// both failed the same way — a 75 s period put consecutive 30 s probes
// half a cycle apart, and even a 300 s period swings back within a long
// video, so near-in-time frames differed more than far-apart ones and
// filmstrip direction went negative. The staged story journey was tried
// next and jumped at section starts for the same reason. The camera
// instead crosses the room once, driven by continuous video progress
// (monotonic in the frame — it cannot jump), while the push-in below
// stays journey-staged so arrivals still land per section. Direction
// from the seed so two tracks walk different lines; counter-pan below
// keeps the screen framed. Stillness LENGTHENS the crossing: a pumping
// track changes its viz constantly, a still one does not, so the camera
// does the travelling the music doesn't — otherwise slow songs go
// static in both layers at once (two flat filmstrips on the slowest
// bank songs). A small two-sine handheld keeps it alive in a hold.
const walkDir = s1 >= 0 ? 1 : -1;
const walkSpan = (1.1 + still * 0.9) * walkDir;
const trav = progress !== null && progress !== undefined ? progress : journey;
// Glide, not drift: the lateral crossing is only one axis of travel.
// A second, slower cosine on a different phase swings the camera across
// the room's middle over ~2 minutes, so the path arcs instead of
// drawing a straight line — straight lines read as static at 30 s
// probe spacing once the endpoints are familiar.
const walk = (trav - 0.5) * 2 * walkSpan
+ Math.cos(time * 0.043 + s2 * 4.2) * walkSpan * 0.55
+ Math.sin(time * 0.23 + s1 * 3.1) * 0.08
+ Math.sin(time * 0.11 + s2 * 5.0) * 0.06;
// The push: story journey 0→1 walks the camera closer over the video,
// staged through per-section steps by the journey curve itself. Sized
// so consecutive 30 s probes see the room at measurably different
// scales — a static camera in a static room is churn, not an arc.
// The dolly ALSO marches on continuous progress underneath the staged
// push, so even a journey that holds for a minute keeps gliding closer.
const push = (journey - 0.5) * 2.2 * room
+ (trav - 0.5) * 1.6 * room;
// Handheld: churn-scaled, two incommensurate sines so it never loops.
const hx = Math.sin(time * 0.9 + s1 * 7.0) * 0.035 * (0.4 + churn)
+ Math.sin(time * 0.23 + s2 * 5.0) * 0.05 * (0.4 + churn);
const hy = Math.cos(time * 0.77 + s2 * 9.0) * 0.03 * (0.4 + churn)
+ Math.sin(time * 0.31 + s1 * 4.0) * 0.04 * (0.4 + churn);
const sway = pCam ? pCam.sway : 0.01;
const swayRate = pCam ? pCam.swayRate : 0.09;
if (planned) {
// A planned mark: the BODY walks the traverse (continuous,
// monotonic — the thing that arcs), while the HEAD takes the
// plan's look target (screen / room). Position marks were tried and
// reverted: discrete positions made held compositions that all
// looked alike, and blending positions with the walk broke the
// walk's time correlation. The look is where the motive lives — a
// pan cut over a gliding body reads as following the action. Jump
// sections cut the body to the arrangement side (the furniture
// swapped; gliding through the old layout is the glitch).
const lim = venue.archetype === 'chamber'
? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 }
: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 };
const isJump = framing.venueJump !== undefined && framing.venueJump !== null;
if (isJump) {
cam.position.set(
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx)),
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy)),
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
);
} else {
// Same traverse body as the legacy path below (walk + rise +
// sway + gaze share): one body, two heads. The plan's per-shot
// size scales the dolly (a close stands nearer).
const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6;
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly;
const rise = (trav - 0.5) * 0.9 * room;
const shiftX = framing.shift[0] * 0.6;
const shiftY = framing.shift[1] * 0.35;
const px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx;
const py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy;
const pz = dolly;
cam.position.set(
Math.max(-lim.x, Math.min(lim.x, px)),
Math.max(lim.yLo, Math.min(lim.yHi, py)),
Math.max(lim.zLo, Math.min(lim.zHi, pz)),
);
}
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
cam.lookAt(planned.look[0], planned.look[1], planned.look[2]);
cam.updateMatrixWorld();
return;
}
// Close enough that the screen fills the frame: the chamber window is
// 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame.
const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6;
const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push);
// A story jump relocates the room camera for the section: the furniture
// swapped at its start (see ArcDriver.venueJumpAt), so the camera
// MUST be somewhere new too — a new room filmed from the old mark
// reads as the glitch, while a new room from a new angle reads as a
// cut. The framing carries the jump side; the traverse continues
// underneath, so the walk resumes from the new mark without snapping
// back. Pure f(framing, time) — the jump id is in the framing.
const jumpShift = (framing && framing.venueJump !== undefined && framing.venueJump !== null)
? framing.shift : null;
// Gaze shifts relocate at section starts; the venue camera takes a
// third of them — the room should sit stiller than the viz, and the
// counter-pan already keeps the screen framed through the traverse.
// (Kept small deliberately: full gaze jumps threw the whole room
// around at every section, ~4x the no-venue section distance.)
// On a jump frame the jump shift replaces the gaze shift outright.
// The mix is continuous in time EXCEPT the jump cut: gaze moves ease
// with the section (they are per-cue plans evaluated live), so the
// camera reframes at every section boundary instead of teleporting.
const shiftX = jumpShift ? jumpShift[0] : framing.shift[0] * 0.6;
const shiftY = jumpShift ? jumpShift[1] : framing.shift[1] * 0.35;
// Vertical rise: the camera climbs as it pushes in — low wide early,
// higher and closer late — so the video gains a second travel axis the
// walk cannot provide. Clamped to the room box: the test asserts
// |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it.
const rise = (trav - 0.5) * 0.9 * room;
const limX = venue.archetype === 'chamber' ? 2.4 : 4.0;
cam.position.set(
Math.max(-limX, Math.min(limX,
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)),
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy,
Math.max(0.6, Math.min(7.5, dolly)),
);
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
// Counter-pan: look back toward the screen by a fraction of the walk, so
// the camera travels without losing the room's subject. Was 0.35 — but
// that cancelled the traverse visually (the camera walked while its
// view stayed pinned, so layout never travelled and direction stayed
// flat). 0.15 lets the walk reframe while keeping the screen in view.
const lookX = cam.position.x * 0.15;
cam.lookAt(lookX, cam.position.y * 0.3 - 0.25, cam.position.z - 5);
cam.updateMatrixWorld();
}
_buildMaterials() {
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
u_base: { value: null },
@@ -271,7 +442,7 @@ export class Compositor {
reset() {
const r = this.renderer;
[this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
this.bloomA, this.bloomB, this.layerTarget, this.vizTarget, this.outputTarget]
.forEach((t) => r.clear(t));
}
@@ -291,7 +462,90 @@ export class Compositor {
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
: this.layers;
for (const layer of active) {
// --- venue RTT path -------------------------------------------------
// When a venue layer is present, the viz must appear ON its screen, not
// composited over it. So: non-venue layers composite into vizTarget,
// the viz texture is bound onto each venue screen, then only venues
// (+ OSD) render fullscreen into layerTarget. OSD rides the fullscreen
// list so it is never baked into the viz texture. Without a venue this
// reduces to the old loop (everything renders via layerTarget).
// OSD detection must not depend on minification: constructor.name does
// not survive a production build. The OSD module is the one layer with
// no family/kind/venue — identify it structurally instead.
const isOSD = (l) => !l.module || (!l.module.kind && !l.module.family && !l.module.venue);
const venueActive = active.filter((l) => l.module && l.module.venue && l.opacity > 0.001);
const hasVenue = venueActive.length > 0;
// The plate belongs on the main screen only: keep it out of the viz
// texture (it would render miniature on every venue screen) and let it
// ride the fullscreen list instead.
const vizLayers = hasVenue ? active.filter((l) => !(l.module && l.module.venue) && !isOSD(l)) : active;
const fsLayers = hasVenue
? active.filter((l) => (l.module && l.module.venue) || isOSD(l))
: active;
if (hasVenue) {
// Composite the viz stack into vizTarget, alternating with spare
// (accumB — still untouched at this point; the fullscreen chain
// below clears accumA first and fully overwrites spare on its first
// blend, so borrowing it here is safe). layerTarget stays the
// per-layer render scratch. A fullscreen blit overwrites its whole
// target, so no clear is needed before each blend.
// Clear to the venue's wash, not black: the shot keys over the
// room's own colour, so the screen shows shot-over-room rather
// than the forbidden shot-floating-on-black. The wash arcs with
// venueState (it is read off live room materials), so it carries
// slow travel while calming section jumps. See Layer.washColor.
{
const wash = venueActive.length ? venueActive[0].washColor() : [0, 0, 0];
r.clear(this.vizTarget, wash[0], wash[1], wash[2], 1);
}
let accumV = this.vizTarget;
let spareV = spare;
for (const layer of vizLayers) {
if (layer.opacity <= 0.001) continue;
r.clear(this.layerTarget);
// prevTexture feeds prev()-style feedback sampling. The viz
// composite is its own chain, so hand it the viz accumulator —
// never the fullscreen history, which the venue screens below
// would otherwise smear back into themselves.
layer.render(r, this.layerTarget, {
timeline,
features,
prevTexture: accumV.texture,
});
const bu = this.blendMaterial.uniforms;
bu.u_base.value = accumV.texture;
bu.u_src.value = this.layerTarget.texture;
bu.u_amount.value = 1.0; // layer opacity already applied in-shader
bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
r.blit(this.blendMaterial, spareV);
const t = accumV; accumV = spareV; spareV = t;
}
// The finished viz must live in vizTarget for the bind below.
if (accumV !== this.vizTarget) {
this.copyMaterial.uniforms.u_tex.value = accumV.texture;
r.blit(this.copyMaterial, this.vizTarget);
}
// Bind AFTER the viz composite is complete: every venue screen
// samples the finished viz, and binding is per-layer (each venue
// owns its materials, never shared).
for (const venue of venueActive) {
try {
if (typeof venue.bindVizTexture === 'function') {
venue.bindVizTexture(this.vizTarget.texture);
} else if (venue.instance && typeof venue.module.bindVizTexture === 'function') {
venue.module.bindVizTexture(venue.instance, this.vizTarget.texture);
}
} catch (err) {
console.warn('[compositor] viz bind failed for', venue.module && venue.module.name, err);
}
}
}
for (const layer of (hasVenue ? fsLayers : active)) {
if (layer.opacity <= 0.001) continue;
r.clear(this.layerTarget);
@@ -391,7 +645,7 @@ export class Compositor {
}
disposeTargets() {
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
[this.layerTarget, this.vizTarget, this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.outputTarget,
this.sharedDepthTarget].forEach((t) => t && t.dispose());
this.sharedDepthTarget = null;
+103 -10
View File
@@ -4,7 +4,13 @@ import {
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
} from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
import { clampValue, isDiscreteParam } from '../params/schema.js';
import { surfaceOf } from '../scenes/surface.js';
import {
CANVAS_BEAT_CAP,
CANVAS_FLUX_CAP,
CANVAS_BEAT_SOFTEN_SPIKE,
} from '../look/comfort.js';
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
@@ -139,6 +145,7 @@ export class Layer {
this.blend = blend;
this.palette = [];
this.personality = null;
this.venueState = null;
}
/**
@@ -185,29 +192,53 @@ export class Layer {
if (!features) return out;
// A — whole-screen guard: canvas scenes (≥50% coverage) must not swerve
// or jump with the beat. Every mid-tempo kick (≈2 Hz at 124 BPM) then
// moves the ENTIRE view in sync — the nausea source on
// ?song=centre&seed=3837488838. Only smaller regions/elements should
// react wildly; they light few tiles. This is the immediate, central cap:
// beat → ≤0.15 and spike→smooth, flux → ≤0.25. Composable scenes are
// untouched. Tunable in look/comfort.js; stale metadata detected via
// checks/metadata.js SOURCES. See engine/agitation.js for the tile metric.
const isCanvas = surfaceOf(this.module) === 'canvas';
for (const [name, r] of Object.entries(reactive)) {
const def = defs[name];
if (!def || def.type === 'palette') continue;
// Rate params multiply absolute time; modulating them jumps the phase
// by elapsed * delta. See params/schema.js RATE_FLAG.
if (def.rate) continue;
// Continuity rule (see params/schema.js isDiscreteParam): a reactive
// count pops members on every beat. Discrete params hold per cue.
if (isDiscreteParam(def)) continue;
const raw = features[r.feature];
if (raw === undefined) continue;
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
let amount = r.amount;
let response = r.response;
if (isCanvas) {
if (r.feature === 'beat') {
amount = Math.min(amount, CANVAS_BEAT_CAP);
if (CANVAS_BEAT_SOFTEN_SPIKE && response === 'spike') response = 'smooth';
} else if (r.feature === 'flux') {
amount = Math.min(amount, CANVAS_FLUX_CAP);
}
}
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), response);
const [lo, hi] = def.range || [0, 1];
const span = hi - lo;
const base = out[name] !== undefined ? out[name] : lo;
if (def.type === 'vec2') {
out[name] = [
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
clampValue(def, [base[0] + shaped * amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * amount * span])[1],
];
} else if (def.type === 'bool') {
out[name] = base;
} else {
out[name] = clampValue(def, base + shaped * r.amount * span);
out[name] = clampValue(def, base + shaped * amount * span);
}
}
return out;
@@ -347,6 +378,7 @@ export class ModelLayer extends Layer {
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
this.camera.position.set(0, 0, 5);
this.actorSpec = options.actorSpec || null;
this.venueSpec = options.venueSpec || null;
// Shared rig injected by Compositor at render time when available.
this.sharedCamera = null;
this.instance = this.module.build({
@@ -355,10 +387,48 @@ export class ModelLayer extends Layer {
seed: this.seed,
params: this.baseParams,
actorSpec: this.actorSpec,
venueSpec: this.venueSpec,
THREE,
});
}
/** Allow the look to swap the venue without rebuilding the layer. */
setVenue(venueSpec) {
this.venueSpec = venueSpec || null;
return this;
}
/**
* Bind the composited viz texture onto this layer's screen (venue RTT).
* No-op unless the module implements bindVizTexture(instance, texture).
* Delegating keeps venue materials owned by the venue module — the
* compositor never touches venue meshes directly.
*/
bindVizTexture(texture) {
if (this.module && typeof this.module.bindVizTexture === 'function' && this.instance) {
this.module.bindVizTexture(this.instance, texture);
}
return this;
}
/**
* Base wash for the viz composite. The shot is keyed over this colour
* instead of over black: a shot floating on black is the composition
* failure the phase 12 rules forbid, miniaturised onto the venue screen —
* and it measured ~5x the no-venue section distance (sparse bright things
* on nothing maximise layout/region jumps). Defaults to a dark neutral;
* venue modules override via washColor(instance) to match their room.
*/
washColor() {
try {
if (this.module && typeof this.module.washColor === 'function' && this.instance) {
const c = this.module.washColor(this.instance);
if (Array.isArray(c) && c.length >= 3) return [c[0], c[1], c[2]];
}
} catch { /* fall through to default */ }
return [0.03, 0.03, 0.04];
}
/** Allow the look to swap the actor without rebuilding the layer. */
setActor(actorSpec) {
this.actorSpec = actorSpec || null;
@@ -369,11 +439,14 @@ export class ModelLayer extends Layer {
const { timeline, features } = ctx;
const w = target ? target.width : renderer.width;
const h = target ? target.height : renderer.height;
// Use the compositor's shared camera when it has been injected; it is
// updated centrally from ArcDriver's framing/gaze so every model layer
// shares one perspective and one depth, and a ground in one layer can
// occlude a subject in another.
const cam = this.sharedCamera || this.camera;
// Who drives the camera: a module with `directCamera: true` owns its
// own rig outright — it drives `camera` inside update() and the shared
// rig is ignored for this layer. Everything else borrows the shared
// camera when injected (updated centrally from framing/gaze so model
// layers share one perspective and one depth buffer), else its own.
// Direct control is analytic like everything else: f(timeline, seed,
// framing, venueState) — seek === playback still holds.
const cam = (this.module.directCamera ? this.camera : (this.sharedCamera || this.camera));
if (cam.aspect !== w / h) {
cam.aspect = w / h;
cam.updateProjectionMatrix();
@@ -391,8 +464,28 @@ export class ModelLayer extends Layer {
framing: this.framing,
opacity: this.opacity,
actorSpec: this.actorSpec,
venueSpec: this.venueSpec,
venueState: this.venueState,
// Song-level arrangement salt (see ArcDriver.venueJumpAt): which
// dressing of the room is live. Null most of the time; the module
// re-hangs only when it changes. Plain field, not on Layer base —
// only venue modules read it.
venueArrangement: this.venueArrangement ?? null,
THREE,
});
// Materials built mid-update (first update, satellite/passer rebuilds
// on discrete-count cuts, venue re-hangs) draw wrong until their
// programs link — up to ten frames on the heaviest scene, carried by
// feedback long after. Compiling synchronously here converts the
// rebuild frame from garbage to correct. Modules set
// instance.needsCompile = true wherever they (re)build geometry in
// update(); build()-time geometry is covered by prewarm instead.
if (this.instance && this.instance.needsCompile) {
this.instance.needsCompile = false;
try {
renderer.compileScene(this.scene, cam);
} catch { /* non-fatal: next warm-up frame converges anyway */ }
}
renderer.renderScene(this.scene, cam, target, true);
}
+26 -1
View File
@@ -9,12 +9,37 @@ import { VERTEX_SHADER } from './shader-contract.js';
*/
export class Renderer {
constructor({ width = 1280, height = 720, canvas = null } = {}) {
this.gl = new THREE.WebGLRenderer({
// three@0.181 requires WebGL2. When the browser blocks it (disabled
// pref/policy, headless shell without SwiftShader), the constructor
// throws — historically a blank page with only a console error, since
// Show is built at module scope in main.js. Catch it here and rethrow
// a message that names the cause and the fix, so every caller (app,
// gallery, filmstrip, venue-gallery) can surface it instead of dying
// silent. this.gl stays null so dispose()/getters degrade safely.
let gl = null;
try {
gl = new THREE.WebGLRenderer({
canvas: canvas || undefined,
antialias: false, // we render through targets; MSAA here buys nothing
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
powerPreference: 'high-performance',
});
} catch (err) {
const reason = err && err.message ? String(err.message).slice(0, 300) : String(err);
throw new Error(
`WebGL2 unavailable — the renderer could not create a WebGL context (${reason}). ` +
`Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ` +
`with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).`,
);
}
if (!gl || !gl.getContext()) {
throw new Error(
'WebGL2 unavailable — the renderer got no GL context back. ' +
'Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ' +
'with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).',
);
}
this.gl = gl;
this.gl.autoClear = false;
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
this.gl.setSize(width, height, false);
+217
View File
@@ -0,0 +1,217 @@
// Whole-screen agitation — flash and large coherent motion + structure as a single comfort score.
//
// Mean luminance flash (engine/flash.js) catches WCAG 2.3.1 but an inverting
// checkerboard leaves the mean unchanged while the retina sees a full flash, and
// a full-frame pan never changes the mean at all yet is the dominant nausea
// source (vection). This module adds spatial reasoning: how much of the VIEW is
// changing together. A ribbon in the centre lights ~15 % of tiles; a coherent
// zoom/pan lights 80–100 %. The two arms are normalised independently and the
// larger one is the agitation — the worst thing the frame is doing right now.
//
// Revision 2: even 0.0 songs with moving rings / whole-screen pattern were
// aggressive. Tile luma threshold 0.05 (~13/255) missed slow ring drift
// (tile mean ~0.02-0.04) and Voronoi recut is a structure event, not a luma
// one. So:
// - threshold 0.05 -> 0.018 (5 luma units register; jitter ~1-2 stays below)
// - MOTION_CEILING 0.55 -> 0.32 (≥32% tiles = whole-screen, not 55%)
// - new third arm: structural distance via checks/variety descriptors
// (96×96 luma pyramid, scale/orient/layout/region/texture). Rings
// translating 2px score ~0.12 structure where tile luma is 0.02.
// Agitation is still max-of-arms (worst thing), now max of three.
import { peakFlashRate } from './flash.js';
import { frameDescriptor } from '../checks/variety/descriptors.js';
import { descriptorDistance } from '../checks/variety/signature.js';
export const FLASH_CEILING = 3; // flashes/s — WCAG 2.3.1 / Harding
export const MOTION_CEILING = 0.32; // ≥32% of tiles active = whole-screen (was 0.55)
export const STRUCTURE_CEILING = 0.12; // structural distance 0..1, ~0.10 separates calm/aggressive
export const WINDOW_SECONDS = 1; // floating window length
export const AGITATION_CEILING = 0.70; // scene-gate + gallery red line
export const WHOLE_THRESHOLD = 0.018; // tile mean luma delta to count active (was 0.05)
// How far below the ceiling a track still reads as calm vs. caution.
export const AGITATION_CAUTION = 0.50;
/**
* Fraction of tiles whose mean luma delta exceeds `threshold`.
*/
export function wholeScreenMotion(a, b, width, height, { grid = 4, threshold = WHOLE_THRESHOLD } = {}) {
if (a.length !== b.length || a.length === 0) return 0;
const cols = Math.max(1, grid | 0);
const rows = cols;
const tileW = Math.max(1, Math.floor(width / cols));
const tileH = Math.max(1, Math.floor(height / rows));
let active = 0;
const total = cols * rows;
for (let ty = 0; ty < rows; ty++) {
for (let tx = 0; tx < cols; tx++) {
const x0 = tx * tileW;
const y0 = ty * tileH;
const x1 = tx === cols - 1 ? width : x0 + tileW;
const y1 = ty === rows - 1 ? height : y0 + tileH;
let sum = 0;
let count = 0;
for (let y = y0; y < y1; y++) {
let base = y * width * 4;
for (let x = x0; x < x1; x++) {
const i = base + x * 4;
const la = (0.2126 * a[i] + 0.7152 * a[i + 1] + 0.0722 * a[i + 2]) / 255;
const lb = (0.2126 * b[i] + 0.7152 * b[i + 1] + 0.0722 * b[i + 2]) / 255;
sum += Math.abs(la - lb);
count++;
}
}
const mean = count ? sum / count : 0;
if (mean > threshold) active++;
}
}
return active / total;
}
/**
* Structural distance 0..1 between two frames (threshold-free).
* Uses the same descriptor the variety harness uses — scale/orient/layout/
* region/texture, colour excluded — via frameDescriptor + descriptorDistance.
* Cheap enough at 96×96; ~0.5ms/frame.
*/
export function wholeScreenStructure(a, b, width, height) {
if (a.length !== b.length || a.length === 0) return 0;
try {
const da = frameDescriptor(a, width, height);
const db = frameDescriptor(b, width, height);
const d = descriptorDistance(da, db);
// Structural mean over the six structural blocks (signature.js blockMean but inlined to avoid extra import)
let sum = 0, n = 0;
for (const k of ['scale', 'orient', 'layout', 'region', 'texture', 'motion']) {
if (d[k] !== undefined) { sum += d[k]; n++; }
}
// When motion block missing (no pair), da/db have no motion — exclude it
// blockMean in signature.js flat weights; do the same here.
return n ? sum / n : 0;
} catch {
return 0;
}
}
function peakFromDeltas(deltas, fps) {
if (!deltas.length) return 0;
const windowFrames = Math.max(1, Math.round(fps * WINDOW_SECONDS));
if (deltas.length <= windowFrames) {
return deltas.reduce((s, v) => s + v, 0) / deltas.length;
}
let worst = 0;
const step = Math.max(1, Math.round(fps / 4));
for (let i = 0; i + windowFrames <= deltas.length; i += step) {
let sum = 0;
for (let j = 0; j < windowFrames; j++) sum += deltas[i + j];
const mean = sum / windowFrames;
if (mean > worst) worst = mean;
}
if ((deltas.length - windowFrames) % step !== 0) {
let sum = 0;
const start = deltas.length - windowFrames;
for (let j = 0; j < windowFrames; j++) sum += deltas[start + j];
const mean = sum / windowFrames;
if (mean > worst) worst = mean;
}
return worst;
}
/**
* Peak whole-screen motion in any WINDOW_SECONDS window (floating, worst window wins).
*/
export function peakWholeScreenMotion(frames, { fps = 60, grid = 4, threshold = WHOLE_THRESHOLD, width, height } = {}) {
if (!frames || frames.length < 2) return 0;
if (width == null || height == null) return 0;
const deltas = [];
for (let i = 1; i < frames.length; i++) {
deltas.push(wholeScreenMotion(frames[i - 1], frames[i], width, height, { grid, threshold }));
}
return peakFromDeltas(deltas, fps);
}
/**
* Peak structural change in any WINDOW_SECONDS window (floating, worst window wins).
* Threshold-free — even subtle pattern shift registers.
*/
export function peakStructure(frames, { fps = 60, width, height } = {}) {
if (!frames || frames.length < 2) return 0;
if (width == null || height == null) return 0;
const deltas = [];
for (let i = 1; i < frames.length; i++) {
deltas.push(wholeScreenStructure(frames[i - 1], frames[i], width, height));
}
return peakFromDeltas(deltas, fps);
}
/**
* Combined comfort score and its components (three arms).
*
* Each arm is normalised against its own ceiling, then the larger one is the
* score. Keeps message actionable ("flash 4.2/s" vs "motion 62%" vs "struct 0.14")
* while giving a single sortable number. Max-of-three preserves "worst thing"
* semantics — a single noisy arm does not sum with the others.
*/
export function agitationScore(luminance, frames, fps, opts = {}) {
const {
grid = 4,
threshold = WHOLE_THRESHOLD,
width,
height,
} = opts;
const peakFlash = peakFlashRate(luminance, fps);
const flashMean = (() => {
const seconds = luminance.length / fps;
if (seconds <= 0) return 0;
let flashes = 0;
{
const extrema = [];
for (let i = 1; i < luminance.length - 1; i++) {
const a = luminance[i - 1], b = luminance[i], c = luminance[i + 1];
if ((b > a && b >= c) || (b < a && b <= c)) extrema.push({ index: i, value: b, isMax: b > a });
}
for (let i = 1; i < extrema.length - 1; i++) {
const prev = extrema[i - 1], here = extrema[i], next = extrema[i + 1];
if (!here.isMax) continue;
if (here.value - prev.value >= 0.1 && here.value - next.value >= 0.1) flashes++;
}
}
return flashes / seconds;
})();
let peakMotion = 0;
let meanMotion = 0;
let peakStruct = 0;
let meanStruct = 0;
if (frames && frames.length >= 2 && width && height) {
peakMotion = peakWholeScreenMotion(frames, { fps, grid, threshold, width, height });
const deltas = [];
for (let i = 1; i < frames.length; i++) deltas.push(wholeScreenMotion(frames[i - 1], frames[i], width, height, { grid, threshold }));
if (deltas.length) meanMotion = deltas.reduce((s, v) => s + v, 0) / deltas.length;
peakStruct = peakStructure(frames, { fps, width, height });
const sDeltas = [];
for (let i = 1; i < frames.length; i++) sDeltas.push(wholeScreenStructure(frames[i - 1], frames[i], width, height));
if (sDeltas.length) meanStruct = sDeltas.reduce((s, v) => s + v, 0) / sDeltas.length;
}
const flashNorm = FLASH_CEILING > 0 ? peakFlash / FLASH_CEILING : 0;
const motionNorm = MOTION_CEILING > 0 ? peakMotion / MOTION_CEILING : 0;
const structNorm = STRUCTURE_CEILING > 0 ? peakStruct / STRUCTURE_CEILING : 0;
const agitation = Math.max(flashNorm, motionNorm, structNorm);
return {
flashRate: flashMean,
peakFlashRate: peakFlash,
peakMotion,
meanMotion,
peakStructure: peakStruct,
meanStructure: meanStruct,
agitation,
};
}
+85
View File
@@ -0,0 +1,85 @@
// Invocation budget — caps cost by refusing before the network.
//
// perLoad: how many LLM calls per page load
// perSession: how many per sessionStorage lifetime (survives reload until tab close)
import { getLlmConfig } from './config.js';
export const LLM_BUDGET = Object.freeze({
perLoad: 3,
perSession: 10,
});
const SESSION_KEY = 'flow-state:llm-budget';
let loadCount = 0;
function readSessionCount() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return 0;
const n = Number(JSON.parse(raw));
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
function writeSessionCount(n) {
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(Math.max(0, Math.floor(n))));
} catch { /* ignore */ }
}
export function getBudgetCounts() {
return {
loadCount,
sessionCount: readSessionCount(),
perLoad: LLM_BUDGET.perLoad,
perSession: LLM_BUDGET.perSession,
};
}
export function canInvoke() {
const cfg = getLlmConfig();
if (!cfg.isConfigured) return { ok: false, reason: 'not-configured', detail: 'Set an AI Gateway API key in ⚙️ settings.' };
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return { ok: false, reason: 'offline', detail: 'Offline — LLM unavailable.' };
}
const counts = getBudgetCounts();
if (counts.loadCount >= counts.perLoad) {
return { ok: false, reason: 'per-load', detail: `Budget exhausted for this page load (${counts.perLoad}). Reload to reset.` };
}
if (counts.sessionCount >= counts.perSession) {
return { ok: false, reason: 'per-session', detail: `Session budget exhausted (${counts.perSession}). Close the tab or clear sessionStorage.` };
}
return { ok: true, reason: 'ok', detail: '', counts };
}
export function recordInvocation() {
loadCount += 1;
writeSessionCount(readSessionCount() + 1);
return getBudgetCounts();
}
/** Refund one invocation — when the model returned empty/useless edits. */
export function refundInvocation() {
if (loadCount > 0) loadCount -= 1;
const sc = readSessionCount();
if (sc > 0) writeSessionCount(sc - 1);
return getBudgetCounts();
}
export function remainingText() {
const c = getBudgetCounts();
const perLoadLeft = Math.max(0, c.perLoad - c.loadCount);
const perSessionLeft = Math.max(0, c.perSession - c.sessionCount);
return `${perLoadLeft}/${c.perLoad} this load · ${perSessionLeft}/${c.perSession} this session`;
}
export function clearBudget() {
loadCount = 0;
try { sessionStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
}
export const LLM_BUDGET_SESSION_KEY = SESSION_KEY;
+88
View File
@@ -0,0 +1,88 @@
// LLM gateway config — localStorage for the secret, env only for non-secrets.
//
// The app is a static Vite site with no server. The API key is a secret and
// must never be baked into the build — it lives only in the browser's
// localStorage (→ ⚙️ dialog). Gateway URL and model are non-secret and may
// come from VITE env as defaults with a localStorage override; the key never
// does.
const STORAGE_KEY = 'flow-state:llm';
const DEFAULTS = {
gatewayUrl: 'https://ai-gateway.vercel.sh/v1',
model: 'meta/muse-spark-1.3-contributor',
apiKey: '',
};
function readEnv() {
try {
const env = import.meta.env || {};
return {
gatewayUrl: env.VITE_AI_GATEWAY_URL || null,
model: env.VITE_AI_MODEL || null,
// apiKey intentionally NOT read from env — see module comment.
};
} catch {
return { gatewayUrl: null, model: null };
}
}
function readStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
/** Resolved config — key from localStorage only (never env); URL/model from localStorage > env > defaults. */
export function getLlmConfig() {
const env = readEnv();
const stored = readStorage();
const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl;
const model = stored.model || env.model || DEFAULTS.model;
const key = typeof stored.apiKey === 'string' ? stored.apiKey.trim() : '';
return {
gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl,
model: String(model || DEFAULTS.model).trim() || DEFAULTS.model,
apiKey: key,
isConfigured: key.length > 0,
defaults: { ...DEFAULTS },
env: { ...env },
stored: { ...stored },
};
}
/** Merge patch into localStorage. Pass null/'' to clear a field. */
export function setLlmConfig(patch) {
const current = readStorage();
const next = { ...current };
for (const k of ['gatewayUrl', 'model', 'apiKey']) {
if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
const v = patch[k];
if (v == null || v === '') {
delete next[k];
} else {
next[k] = String(v).trim();
}
}
}
try {
if (!Object.keys(next).length) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// storage full / blocked — ignore, caller sees isConfigured false
}
return getLlmConfig();
}
export function clearLlmConfig() {
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
return getLlmConfig();
}
export const LLM_STORAGE_KEY = STORAGE_KEY;
export const LLM_DEFAULTS = DEFAULTS;
+270
View File
@@ -0,0 +1,270 @@
// LLM song-tweak — gateway caller (Vite/browser).
//
// Prompt/schema live in ./spec.js so `node --test` can import them without
// pulling in `src/scenes/metadata.json` (which needs Vite's JSON handling).
// This file adds the look digest (describeLook/validateLook) and the actual
// `ai`/`@ai-sdk/openai` call, which only run in the browser bundle.
import { generateObject, streamObject } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { getLlmConfig } from './config.js';
import { canInvoke, recordInvocation, refundInvocation, getBudgetCounts } from './budget.js';
import { describeLook } from '../look/LookGenerator.js';
import { validateLook } from '../look/composition-validator.js';
import {
TweakResultSchema,
TweakEditsSchema,
ALLOWED_KEYS,
ARRANGEMENT_VALUES,
clampEdits,
buildTweakPrompt as buildTweakPromptPure,
} from './spec.js';
export { TweakResultSchema, TweakEditsSchema, ALLOWED_KEYS, ARRANGEMENT_VALUES, clampEdits };
const clamp01 = (x) => Math.max(0, Math.min(1, x));
function lookDigestForPrompt(look) {
let validation = null;
try { validation = validateLook(look, null); } catch { /* ignore */ }
const sections = (look.sections || []).map((s) => {
const primary = s.layers && s.layers.find((l) => !l.role || l.role !== 'ground');
const name = primary ? primary.module.name : (s.layers && s.layers[0] ? s.layers[0].module.name : '?');
const layerNames = (s.layers || []).map((l) => `${l.role === 'ground' ? 'ground:' : ''}${l.module.name}(${l.blend || '?'})`).join(' + ');
return { index: s.index, kind: s.kind, name, layers: layerNames };
});
return {
describe: (() => { try { return describeLook(look); } catch { return ''; } })(),
director: look.director,
paletteScheme: look.paletteScheme || (look.paletteSchemes && look.paletteSchemes[0]) || null,
sections,
validation: validation ? {
ok: validation.ok,
refused: validation.refused || [],
songRefused: !!validation.songRefused,
perSection: (validation.sections || []).map((v) => ({
index: v.index, kind: v.kind, ok: v.ok, desired: Number(v.desired.toFixed(3)), predicted: Number(v.predicted.toFixed(3)), reason: v.reason || null,
})),
} : null,
};
}
/** Prompt builder — wraps the pure one with a Look→digest step for browser callers. */
export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '', frames = [] } = {}) {
const digest = lookDigest != null ? lookDigest : (look ? lookDigestForPrompt(look) : null);
// Frames are not embedded as text — they become image parts in the message.
// Pass them through so spec can note count in system prompt.
return buildTweakPromptPure({ spec, track, lookDigest: digest, hint, frames });
}
export function lookDigestForPromptExport(look) { return lookDigestForPrompt(look); }
function toErrorMessage(err) {
if (!err) return 'Unknown error';
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
try { return JSON.stringify(err).slice(0, 600); } catch { return String(err); }
}
function attachFramesToMessages(messages, frames) {
if (!frames || !frames.length) return messages;
// Find the user message and turn its string content into parts with images.
return messages.map((m) => {
if (m.role !== 'user') return m;
const text = typeof m.content === 'string' ? m.content : String(m.content || '');
const parts = [{ type: 'text', text }];
for (const f of frames) {
if (!f || !f.dataUrl || typeof f.dataUrl !== 'string') continue;
// Only keep reasonable length data URLs; skip broken placeholders.
if (f.dataUrl.length < 100 || !f.dataUrl.startsWith('data:')) continue;
parts.push({ type: 'image', image: f.dataUrl });
}
// If only text, keep simple string form for models that prefer it.
if (parts.length === 1) return m;
return { role: 'user', content: parts };
});
}
/**
* Call the gateway for one tweak. Checks budget, builds prompt (with optional
* 32×32 thumbnails), streams or generates the object, clamps and returns.
*
* @param {{spec: object, track: object, look: object, hint?: string, frames?: Array, config?: object, onProgress?: (evt:{stage:string, partial?:object, text?:string})=>void}} opts
* @returns {Promise<{rationale:string, edits:object, confidence:number, usage?:object, raw:any}>}
*/
export async function requestSongTweak({ spec, track, look, hint = '', frames = null, config = null, onProgress = null } = {}) {
const cfg = config || getLlmConfig();
if (!cfg.isConfigured) {
const err = new Error('LLM not configured — set an AI Gateway API key in ⚙️ settings.');
err.code = 'NOT_CONFIGURED';
throw err;
}
const gate = canInvoke();
if (!gate.ok) {
const err = new Error(gate.detail || 'Budget exhausted');
err.code = gate.reason === 'per-load' ? 'BUDGET_PER_LOAD' : gate.reason === 'per-session' ? 'BUDGET_PER_SESSION' : 'BUDGET';
throw err;
}
const safeFrames = Array.isArray(frames) ? frames.filter((f) => f && f.dataUrl && f.dataUrl.length > 100) : [];
const messages = attachFramesToMessages(buildTweakPrompt({ spec, track, look, hint, frames: safeFrames }), safeFrames);
recordInvocation();
const isDev = import.meta.env && import.meta.env.DEV;
const proxyFetch = async (url, init = {}) => {
const headers = new Headers(init.headers || {});
if (isDev) headers.set('x-llm-gateway', cfg.gatewayUrl);
console.info('[llm] fetch', isDev ? '→ /__llm (proxied to ' + cfg.gatewayUrl + ')' : '→ ' + cfg.gatewayUrl, String(url).slice(0, 120));
return fetch(url, { ...init, headers });
};
const provider = createOpenAI({
baseURL: isDev ? '/__llm/v1' : cfg.gatewayUrl,
apiKey: cfg.apiKey,
fetch: isDev ? proxyFetch : undefined,
});
const modelId = cfg.model;
console.groupCollapsed(`[llm] requestSongTweak — ${modelId}${hint ? ` · hint: "${hint.slice(0, 80)}"` : ''}${safeFrames.length ? ` · ${safeFrames.length} frames` : ''}`);
console.info('[llm] model', modelId, 'gateway', cfg.gatewayUrl, isDev ? '(via /__llm dev proxy)' : '(direct)');
console.info('[llm] budget after record', getBudgetCounts());
console.log('[llm] messages', messages);
if (safeFrames.length) console.log('[llm] frames', safeFrames.map((f) => ({ kind: f.kind, time: f.time, frame: f.frame, bytes: f.dataUrl.length })));
console.log('[llm] spec before', spec);
console.groupEnd();
const emit = (evt) => { try { if (typeof onProgress === 'function') onProgress(evt); } catch { /* ignore */ } };
// Prefer streaming so the UI can show status as we wait. Fall back to
// generateObject if the gateway/model doesn't support streaming.
const useStream = typeof onProgress === 'function';
if (useStream) {
try {
const started = performance.now();
emit({ stage: 'streaming', text: 'Contacting LLM…' });
const stream = await streamObject({
model: provider.chat(modelId),
schema: TweakResultSchema,
messages,
temperature: 0.7,
providerOptions: { openai: { strictJsonSchema: false, reasoningEffort: 'low' } },
});
let lastPartial = null;
let lastText = '';
for await (const part of stream.partialObjectStream) {
lastPartial = part;
// Derive a short status from whatever is already materialized.
const r = part && part.rationale ? String(part.rationale).slice(0, 120) : '';
const editsKeys = part && part.edits ? Object.keys(part.edits).filter((k) => part.edits[k] != null) : [];
const text = r ? `… ${r}` : (editsKeys.length ? `edits: ${editsKeys.join(', ')}` : '… thinking');
if (text !== lastText) {
lastText = text;
emit({ stage: 'partial', partial: part, text });
console.info('[llm] stream partial', part);
}
}
const final = await stream.object;
const usage = await stream.usage.catch(() => null);
const elapsed = ((performance.now() - started) / 1000).toFixed(1);
const raw = final || lastPartial || {};
const edits = clampEdits(raw.edits || {});
const rationale = String(raw.rationale || '').trim().slice(0, 800) || 'No rationale provided.';
const confidence = Number.isFinite(raw.confidence) ? clamp01(raw.confidence) : 0.5;
console.groupCollapsed(`[llm] stream done in ${elapsed}s — ${Object.keys(edits).length ? Object.keys(edits).join(', ') : 'no edits'}`);
console.log('[llm] raw', raw);
console.log('[llm] clamped edits', edits);
console.log('[llm] rationale', rationale);
console.log('[llm] confidence', confidence, 'usage', usage || null);
console.info('[llm] diff', Object.keys(edits).map((k) => `${k}: ${spec[k]} → ${edits[k]}`).join(' · ') || '(none — all null)');
console.groupEnd();
emit({ stage: 'done', partial: raw, text: rationale.slice(0, 120) });
if (!Object.keys(edits).length) {
refundInvocation();
const err = new Error('LLM suggested no changes — the current generation already looks good. Try a hint or reroll.');
err.code = 'NO_EDITS';
err.result = { rationale, edits: {}, confidence, usage: usage || null, raw };
throw err;
}
return { rationale, edits, confidence, usage: usage || null, raw };
} catch (err) {
// If streaming failed before producing a result (e.g. gateway doesn't
// support it), fall through to non-streaming generateObject below —
// unless it's our own NO_EDITS signal.
if (err && err.code === 'NO_EDITS') throw err;
const msg = toErrorMessage(err).toLowerCase();
const isStreamUnsupported = msg.includes('stream') || msg.includes('not supported') || msg.includes('unsupported');
if (!isStreamUnsupported) {
// Real LLM error — surface as usual (don't silently fall back and
// hide it behind a second call that also fails).
const lower = msg.toLowerCase();
if (!err.code) {
if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('invalid api key')) err.code = 'AUTH';
else if (lower.includes('429') || lower.includes('rate')) err.code = 'RATE_LIMIT';
else if (lower.includes('timeout') || lower.includes('timed out')) err.code = 'TIMEOUT';
else err.code = 'LLM_ERROR';
}
if (!err.message || err.message === '[object Object]') err.message = toErrorMessage(err);
console.error('[llm] stream failed', err);
throw err;
}
console.warn('[llm] stream not supported, falling back to generateObject —', err.message);
}
}
try {
const started = performance.now();
const result = await generateObject({
model: provider.chat(modelId),
schema: TweakResultSchema,
messages,
temperature: 0.7,
maxRetries: 0,
providerOptions: { openai: { strictJsonSchema: false, reasoningEffort: 'low' } },
});
const elapsed = ((performance.now() - started) / 1000).toFixed(1);
const raw = result.object || {};
const edits = clampEdits(raw.edits || {});
const rationale = String(raw.rationale || '').trim().slice(0, 800) || 'No rationale provided.';
const confidence = Number.isFinite(raw.confidence) ? clamp01(raw.confidence) : 0.5;
console.groupCollapsed(`[llm] response in ${elapsed}s — ${Object.keys(edits).length ? Object.keys(edits).join(', ') : 'no edits'}`);
console.log('[llm] raw', raw);
console.log('[llm] clamped edits', edits);
console.log('[llm] rationale', rationale);
console.log('[llm] confidence', confidence, 'usage', result.usage || null);
console.info('[llm] diff', Object.keys(edits).map((k) => `${k}: ${spec[k]} → ${edits[k]}`).join(' · ') || '(none — all null)');
console.groupEnd();
if (!Object.keys(edits).length) {
refundInvocation();
const err = new Error('LLM suggested no changes — the current generation already looks good. Try a hint or reroll.');
err.code = 'NO_EDITS';
err.result = { rationale, edits: {}, confidence, usage: result.usage, raw: result.object };
throw err;
}
return { rationale, edits, confidence, usage: result.usage || null, raw: result.object };
} catch (err) {
const msg = toErrorMessage(err);
const lower = msg.toLowerCase();
if (err && err.code === 'NO_EDITS') {
console.warn('[llm] no edits —', err.message, err.result);
throw err;
}
console.error('[llm] request failed', err, '\nmessage:', msg);
if (!err.code) {
if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('invalid api key')) err.code = 'AUTH';
else if (lower.includes('429') || lower.includes('rate')) err.code = 'RATE_LIMIT';
else if (lower.includes('timeout') || lower.includes('timed out')) err.code = 'TIMEOUT';
else err.code = 'LLM_ERROR';
}
if (!err.message || err.message === '[object Object]') err.message = msg;
throw err;
}
}
+250
View File
@@ -0,0 +1,250 @@
// Pure prompt/schema logic — no gateway, no LookGenerator/Scene imports.
//
// Split from llmTweak.js so `node --test` can import it without pulling in
// `src/scenes/metadata.json` (which requires Vite's JSON handling and fails
// under bare Node's import-assertion check).
import { z } from 'zod';
import { ARRANGEMENTS } from '../audio/synth.js';
const ARRANGEMENT_VALUES = Object.keys(ARRANGEMENTS);
export { ARRANGEMENT_VALUES };
export const TweakEditsSchema = z.object({
bpm: z.number().int().min(60).max(180).nullable(),
brightness: z.number().min(0).max(1).nullable(),
noise: z.number().min(0).max(1).nullable(),
noiseColour: z.number().min(0).max(1).nullable(),
dynamics: z.number().min(0).max(1).nullable(),
transition: z.number().min(0).max(1).nullable(),
arrangement: z.enum(ARRANGEMENT_VALUES).nullable(),
});
export const TweakResultSchema = z.object({
rationale: z.string().min(1).max(800),
edits: TweakEditsSchema,
confidence: z.number().min(0).max(1),
});
export const ALLOWED_KEYS = ['bpm', 'brightness', 'noise', 'noiseColour', 'dynamics', 'transition', 'arrangement'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const round2 = (x) => Math.round(x * 100) / 100;
export function clampEdits(edits) {
if (!edits || typeof edits !== 'object') return {};
const out = {};
if (edits.bpm != null) {
const n = Number(edits.bpm);
if (Number.isFinite(n)) out.bpm = Math.max(60, Math.min(180, Math.round(n)));
}
for (const k of ['brightness', 'noise', 'dynamics', 'transition']) {
if (edits[k] != null) {
const n = Number(edits[k]);
if (Number.isFinite(n)) out[k] = round2(clamp01(n));
}
}
if (Object.prototype.hasOwnProperty.call(edits, 'noiseColour')) {
const v = edits.noiseColour;
if (v == null) out.noiseColour = null;
else {
const n = Number(v);
if (Number.isFinite(n)) out.noiseColour = round2(clamp01(n));
}
}
if (edits.arrangement != null && ARRANGEMENT_VALUES.includes(edits.arrangement)) {
out.arrangement = edits.arrangement;
}
return out;
}
/**
* Build chat messages from already-serializable inputs.
*
* `lookDigest` is the pre-built digest (describeLook + validateLook) — callers
* that have a live Look should build it via llmTweak.lookDigestForPrompt(look)
* and pass it here, so this module never imports scene metadata.
*
* Kept compatible with the old `{spec,track,look,hint}` shape by accepting
* either `look` (no-op here) or `lookDigest`.
*/
export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '', frames = [] } = {}) {
const r2 = (v) => {
const n = Number(v);
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
};
const r3 = r2;
const summary = track ? track.summary : null;
const digest = lookDigest ?? (look ? look : null);
const specForPrompt = spec ? {
bpm: spec.bpm != null ? Math.round(Number(spec.bpm)) : null,
brightness: spec.brightness != null ? r2(spec.brightness) : null,
noise: spec.noise != null ? r2(spec.noise) : null,
noiseColour: spec.noiseColour != null ? r2(spec.noiseColour) : null,
dynamics: spec.dynamics != null ? r2(spec.dynamics) : null,
transition: spec.transition != null ? r2(spec.transition) : null,
arrangement: spec.arrangement ?? null,
duration: spec.duration != null ? r2(spec.duration) : null,
seed: spec.seed ?? null,
} : null;
const sectionsDetailed = track ? (track.sections || []).map((s, i) => ({
index: Number.isFinite(s.index) ? s.index : i,
kind: s.kind || 'unknown',
start: r2(s.startSec != null ? s.startSec : (s.start ?? 0)),
end: r2(s.endSec != null ? s.endSec : (s.end ?? 0)),
duration: r2(s.duration ?? ((s.endSec != null ? s.endSec : (s.end ?? 0)) - (s.startSec != null ? s.startSec : (s.start ?? 0)))),
energy: r3(s.energy ?? 0),
flux: r3(s.flux ?? 0),
centroid: r3(s.centroid ?? 0),
flatness: r3(s.flatness ?? 0),
width: r3(s.width ?? 0),
slope: r3(s.slope ?? 0),
endEnergy: r3(s.endEnergy ?? 0),
})) : [];
// trackSummary — extended
let trackSummary = null;
if (summary) {
trackSummary = {
bpm: summary.bpm ?? track?.tempo?.bpm ?? null,
meanCentroid: summary.meanCentroid != null ? r3(summary.meanCentroid) : null,
meanFlatness: summary.meanFlatness != null ? r3(summary.meanFlatness) : null,
meanWidth: summary.meanWidth != null ? r3(summary.meanWidth) : undefined,
meanLoudness: summary.meanLoudness != null ? r3(summary.meanLoudness) : null,
dynamicRange: summary.dynamicRange != null ? r3(summary.dynamicRange) : null,
tempoConfidence: summary.tempoConfidence != null ? r3(summary.tempoConfidence) : (track?.tempo?.confidence != null ? r3(track.tempo.confidence) : null),
frameCount: track?.frameCount ?? summary.frameCount ?? null,
fps: track?.fps ?? summary.fps ?? null,
duration: r2(track?.duration ?? summary.duration ?? 0),
sections: sectionsDetailed,
};
if (summary.bandBalance) {
trackSummary.bandBalance = {
sub: r3(summary.bandBalance.sub ?? 0),
low: r3(summary.bandBalance.low ?? 0),
mid: r3(summary.bandBalance.mid ?? 0),
high: r3(summary.bandBalance.high ?? 0),
air: r3(summary.bandBalance.air ?? 0),
};
}
// remove undefined meanWidth if not present to keep payload compact
if (trackSummary.meanWidth === undefined) delete trackSummary.meanWidth;
} else if (sectionsDetailed.length || track) {
// fallback when summary missing but track exists (e.g. synthetic test track)
trackSummary = {
bpm: track?.tempo?.bpm ?? null,
frameCount: track?.frameCount ?? null,
fps: track?.fps ?? null,
duration: r2(track?.duration ?? 0),
sections: sectionsDetailed,
};
if (track?.summary?.bandBalance) {
trackSummary.bandBalance = {
sub: r3(track.summary.bandBalance.sub ?? 0),
low: r3(track.summary.bandBalance.low ?? 0),
mid: r3(track.summary.bandBalance.mid ?? 0),
high: r3(track.summary.bandBalance.high ?? 0),
air: r3(track.summary.bandBalance.air ?? 0),
};
}
}
// tempo
let tempo = null;
if (track?.tempo) {
tempo = {
bpm: r2(track.tempo.bpm ?? summary?.bpm ?? 0),
confidence: r3(track.tempo.confidence ?? 0),
beatsPerBar: track.tempo.beatsPerBar ?? 4,
downbeats: Array.isArray(track.tempo.downbeats) ? track.tempo.downbeats.length : 0,
};
} else if (summary?.bpm != null) {
tempo = { bpm: r2(summary.bpm), confidence: r3(summary.tempoConfidence ?? 0), beatsPerBar: 4, downbeats: 0 };
}
// normalization
let normalization = null;
if (track?.normalization && typeof track.normalization === 'object') {
normalization = {};
for (const [k, v] of Object.entries(track.normalization)) {
if (v && typeof v === 'object' && v.lo != null && v.hi != null) {
normalization[k] = { lo: r3(v.lo), hi: r3(v.hi) };
}
}
if (!Object.keys(normalization).length) normalization = null;
}
// stats — derived
let stats = null;
if (sectionsDetailed.length || tempo || track) {
const kinds = [...new Set(sectionsDetailed.map((s) => s.kind))];
const avgEnergy = sectionsDetailed.length
? r3(sectionsDetailed.reduce((a, s) => a + s.energy, 0) / sectionsDetailed.length)
: 0;
let maxSec = null;
let maxE = -Infinity;
for (const s of sectionsDetailed) if (s.energy > maxE) { maxE = s.energy; maxSec = s; }
const duration = track?.duration ?? summary?.duration ?? 0;
const bpmVal = tempo?.bpm ?? summary?.bpm ?? 120;
const bpb = tempo?.beatsPerBar ?? 4;
const bars = bpmVal > 0 && duration > 0 ? Math.round(duration / ((60 / bpmVal) * bpb)) : 0;
stats = {
totalSections: sectionsDetailed.length,
kinds,
avgEnergy,
maxEnergySection: maxSec ? { index: maxSec.index, kind: maxSec.kind, energy: maxSec.energy } : null,
bpm: tempo?.bpm ?? null,
beatsPerBar: tempo?.beatsPerBar ?? null,
downbeats: tempo?.downbeats ?? 0,
bars,
duration: r2(duration),
fps: track?.fps ?? summary?.fps ?? null,
frameCount: track?.frameCount ?? summary?.frameCount ?? null,
};
}
const hintBlock = hint && String(hint).trim()
? `User hint: "${String(hint).trim().slice(0, 300)}"`
: 'No user hint.';
const frameNote = Array.isArray(frames) && frames.length
? `You are also given ${frames.length} low-res thumbnails (32×32) sampled across the song in order — use them to judge color, structure, and motion.`
: null;
const system = [
'You are a producer for a generative music-video system called flow-state.',
'You suggest tweaks to the *song synthesis parameters* so the next generation looks better.',
'You do NOT edit the visualization directly — only the 7 song knobs below. The system re-synthesizes audio and regenerates the look.',
'',
'Rules:',
'You have FULL control — change any subset of the 7 knobs by any amount within valid ranges, including large jumps, arrangement swaps, or bpm shifts. Prefer impactful changes that clearly improve visuals; keep duration fixed.',
'- Valid bpm 60-180 int, brightness/noise/dynamics/transition/noiseColour 0-1, arrangement one of: ' + ARRANGEMENT_VALUES.join(', ') + '.',
'- noiseColour may be null (follows brightness) or 0-1; leave it null unless you have a reason.',
'- All 7 edit fields MUST be present in edits — use null for unchanged (not omitted). Example: { bpm: null, brightness: 0.6, noise: null, noiseColour: null, dynamics: null, transition: null, arrangement: null }',
'- Explain your reasoning in 1-2 sentences (rationale).',
'- If the look is already good, return all-null edits.',
hintBlock,
frameNote,
].filter(Boolean).join('\n');
const userPayload = {
songSpec: specForPrompt,
trackSummary,
sections: sectionsDetailed,
tempo,
normalization,
lookDigest: digest,
stats,
};
const user = JSON.stringify(userPayload, null, 2);
return [
{ role: 'system', content: system },
{ role: 'user', content: `Current generation:\n\`\`\`json\n${user}\n\`\`\`\n\nSuggest edits as { rationale, edits, confidence }.` },
];
}
+167
View File
@@ -0,0 +1,167 @@
export function downscaleRgbaToDataUrl(pixels, srcW, srcH, dstSize) {
const dst = new Uint8ClampedArray(dstSize * dstSize * 4);
for (let y = 0; y < dstSize; y++) {
const y0 = Math.floor((y * srcH) / dstSize);
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcH) / dstSize));
for (let x = 0; x < dstSize; x++) {
const x0 = Math.floor((x * srcW) / dstSize);
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcW) / dstSize));
let r = 0, g = 0, b = 0, c = 0;
for (let sy = y0; sy < y1; sy++)
for (let sx = x0; sx < x1; sx++) {
const i = (sy * srcW + sx) * 4;
r += pixels[i]; g += pixels[i + 1]; b += pixels[i + 2]; c++;
}
const o = (y * dstSize + x) * 4;
dst[o] = c ? Math.round(r / c) : 0;
dst[o + 1] = c ? Math.round(g / c) : 0;
dst[o + 2] = c ? Math.round(b / c) : 0;
dst[o + 3] = 255;
}
}
if (typeof document !== 'undefined' && document.createElement) {
try {
const canvas = document.createElement('canvas');
canvas.width = dstSize; canvas.height = dstSize;
const ctx = canvas.getContext('2d');
if (ctx) { ctx.putImageData(new ImageData(dst, dstSize, dstSize), 0, 0); return canvas.toDataURL('image/jpeg', 0.6); }
} catch { /* fallback */ }
}
return 'data:image/jpeg;base64,';
}
function uniformFrames(count, frameCount) {
const seen = new Set(); const out = [];
for (let i = 0; i < count; i++) {
const f = Math.max(0, Math.min(frameCount - 1, Math.round(((i + 0.5) / count) * frameCount)));
if (!seen.has(f)) { seen.add(f); out.push(f); }
}
return out;
}
function pickFrames(show, count, frameCount, fps) {
const sections = show.track && show.track.sections;
if (sections && sections.length) {
const mids = []; const seen = new Set();
for (const s of sections) {
let mid;
if (Number.isFinite(s.startFrame) && Number.isFinite(s.endFrame)) mid = Math.round((s.startFrame + s.endFrame) / 2);
else if (Number.isFinite(s.start) && Number.isFinite(s.end)) mid = Math.round(((s.start + s.end) / 2) * fps);
else continue;
mid = Math.max(0, Math.min(frameCount - 1, mid));
if (!seen.has(mid)) { seen.add(mid); mids.push(mid); }
}
if (!mids.length) return uniformFrames(count, frameCount);
if (mids.length >= count) {
if (mids.length === count) return mids;
mids.sort((a, b) => a - b);
const out = [];
for (let i = 0; i < count; i++) {
const idx = Math.min(mids.length - 1, Math.floor(((i + 0.5) / count) * mids.length));
const v = mids[idx]; if (!out.includes(v)) out.push(v);
}
return out;
}
const need = count - mids.length;
for (const f of uniformFrames(need, frameCount)) if (!seen.has(f)) { seen.add(f); mids.push(f); }
if (mids.length < count) for (const f of uniformFrames(count, frameCount)) if (!seen.has(f) && mids.length < count) { seen.add(f); mids.push(f); }
const sorted = [...seen].sort((a, b) => a - b);
if (sorted.length <= count) return sorted;
const out = [];
for (let i = 0; i < count; i++) {
const idx = Math.min(sorted.length - 1, Math.floor(((i + 0.5) / count) * sorted.length));
const v = sorted[idx]; if (!out.includes(v)) out.push(v);
}
return out;
}
return uniformFrames(count, frameCount);
}
/**
* Capture thumbnails — mixed res per "scene borders" request.
*
* Each sampled frame is classified: frames at the track's first frame,
* last frame, or within 0.5s of any section start/end are "border"
* frames and rendered at 64×64; frames in the interior of a section are
* rendered at 32×32 (between-border). This gives the vision model high
* detail where cuts happen and low cost elsewhere.
*
* Returns Array<{ kind, time, frame, dataUrl, size, role }> where
* role is 'border'|'inner'. Backward compat: callers that ignore size
* still see the same shape plus a new field.
*/
export async function captureThumbnails(show, { count = 8, borderSize = 64, innerSize = 32 } = {}) {
// Backward compat shim: old callers pass {size: N}. Respect it as innerSize with borderSize = min(innerSize*2, 128).
if (arguments[1] && Object.prototype.hasOwnProperty.call(arguments[1], 'size') && !Object.prototype.hasOwnProperty.call(arguments[1], 'innerSize')) {
const legacySize = arguments[1].size;
if (typeof legacySize === 'number' && legacySize > 0) {
innerSize = legacySize | 0;
borderSize = Math.min(128, innerSize * 2);
}
}
if (!show || !show.ready || !show.track || !show.engine) return [];
const fps = show.fps || 60;
const frameCount = show.frameCount || (show.track && show.track.frameCount) || Math.round((show.track.duration || 0) * fps) || 0;
if (!frameCount) return [];
const originalFrame = show.timeline ? show.timeline.frame : 0;
const origW = show.engine.width, origH = show.engine.height;
const frames = pickFrames(show, count, frameCount, fps).sort((a, b) => a - b);
// Precompute border detection from section starts
const boundaries = [];
if (show.track && show.track.sections) {
for (const s of show.track.sections) {
if (Number.isFinite(s.startFrame)) boundaries.push(s.startFrame);
else if (Number.isFinite(s.start)) boundaries.push(Math.round(s.start * fps));
}
if (frameCount > 0) boundaries.push(frameCount - 1);
}
const halfSec = Math.round(0.5 * fps);
const isBorder = (f) => {
if (f === 0 || f === frameCount - 1) return true;
for (const b of boundaries) if (Math.abs(f - b) <= halfSec) return true;
return false;
};
const out = [];
try {
for (const frame of frames) {
try {
if (typeof show.warmUp === 'function') {
const wf = typeof show.warmupFrames === 'function' ? show.warmupFrames() : 120;
show.warmUp(frame, Math.min(120, wf));
} else if (show.engine.compositor) {
if (typeof show.engine.warmUp === 'function') show.engine.warmUp(frame, 120);
else show.engine.compositor.reset();
}
} catch { /* ignore */ }
let target; try { target = show.renderFrame(frame); } catch { continue; }
let pixels; try { pixels = show.readPixels(target); } catch { continue; }
if (!pixels || !pixels.length) continue;
const size = isBorder(frame) ? borderSize : innerSize;
let dataUrl = ''; try { dataUrl = downscaleRgbaToDataUrl(pixels, origW, origH, size); } catch { dataUrl = 'data:image/jpeg;base64,'; }
let kind = '';
try {
const sec = show.track.sectionAt ? show.track.sectionAt(frame) : null;
if (sec && sec.kind) kind = sec.kind;
else if (show.track.sections) for (const s of show.track.sections) if (Number.isFinite(s.startFrame) && frame >= s.startFrame && frame < s.endFrame) { kind = s.kind || ''; break; }
} catch { /* ignore */ }
out.push({ kind: kind || '', time: frame / fps, frame, dataUrl, size, role: isBorder(frame) ? 'border' : 'inner' });
}
} finally {
try {
if (typeof show.seek === 'function') show.seek(originalFrame);
else if (show.timeline && typeof show.timeline.seek === 'function') {
show.timeline.seek(originalFrame);
try { const t = show.renderFrame(originalFrame); if (typeof show.present === 'function') show.present(t); } catch { /* ignore */ }
}
} catch { /* ignore */ }
try {
if (show.engine && (show.engine.width !== origW || show.engine.height !== origH)) {
if (typeof show.engine.setSize === 'function') show.engine.setSize(origW, origH);
else if (typeof show.setSize === 'function') show.setSize(origW, origH);
}
} catch { /* ignore */ }
}
return out;
}
+368 -7
View File
@@ -1,11 +1,12 @@
import { createLayer } from '../engine/Layer.js';
import { Rng } from '../engine/rng.js';
import { clampValue } from '../params/schema.js';
import { clampValue, isDiscreteParam } from '../params/schema.js';
import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette, lerpPalettes } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';
import { planGaze, gazeAt } from './Camera.js';
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
import { planVenueShots, venueShotAt, describeVenueShot } from './venueShots.js';
import { storyStateAt, storyForSection, NEUTRAL_STATE } from './Story.js';
import { subjectIndexOf, isGround } from './stack.js';
import { groundPersonalityFrom } from '../scenes/surface.js';
import { derivePalettePlan, directorByName } from './directors.js';
@@ -49,6 +50,7 @@ export class ArcDriver {
? look.framing : null;
this._planFraming();
this._planGaze();
this._planVenueShots();
this._planPalette();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
}
@@ -95,7 +97,48 @@ export class ArcDriver {
_planGaze() {
if (!this.look.camera) return;
const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0);
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng);
// Venue story jumps force a gaze relocate on the same cue, so the viz
// cut covers the room re-hang. Computed from the same venueJumpAt the
// renderer reads — one decision, two expressions.
let force = null;
if (this.look && this.look.venue && this.look.venue.rearrangeAt) {
force = new Set();
for (const cue of this.cues) {
if (this.venueJumpAt(cue)) force.add(cue.index);
}
if (!force.size) force = null;
}
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng, force);
}
/**
* Plan the ROOM camera's shots across the whole video.
*
* After gaze, for the same reason: the room's shot sizes lean on the same
* framing style the viz uses, and a seek must find the same mark playback
* does. No venue (or a hand-built look) → no plan; the room camera falls
* back to its traverse path (see Compositor._updateVenueCamera).
*/
_planVenueShots() {
const venue = this.look && this.look.venue;
if (!venue || !venue.module) { this.venueShots = null; return; }
// The room plans with an unlocked style even on a locked track: locked
// is the viz's decision to never change size, and a locked-off screen
// inside a room that still pushes wide→close is a legitimate combination
// (the screen holds still; the house moves). Pinning the room too froze
// every dolly on locked tracks.
const style = this.look.framing && this.look.framing.mode === 'locked'
? { ...this.look.framing, mode: 'gentle', range: 0.45, changeChance: 0.35 }
: this.look.framing;
const rng = new Rng((this.look.seed ^ 0x6d1f4a77) >>> 0);
// The viz gaze move per cue, so the room shot inherits a fraction of
// the viz camera's relocation: the two cameras cut in the same
// direction, and the room keeps the per-cue variation the traverse era
// got from the gaze coupling (dropping it flattened everything).
const gazeFrom = (this.gaze || []).map((m) => (m && m.from) || [0, 0]);
this.venueShots = planVenueShots(
this.cues, this.look.sections, venue.archetype,
style, (cue) => this.venueJumpAt(cue), rng, gazeFrom);
}
/**
@@ -221,37 +264,110 @@ export class ArcDriver {
return stack[slot] || null;
}
/** Resolve the VenueSpec for the venue ground. */
_venueFor() {
// Venue is the track's place — one per track. Return the Look's venue when requested.
return (this.look && this.look.venue) ? this.look.venue : null;
}
/** Resolve the ActorSpec for a layer, if the module requests one. */
_actorFor(module) {
const actors = this.look.actors;
if (!actors || !module || !module.actor) return null;
if (!actors || !module) return null;
// Venues are furnished with the track's monolith body: the room's
// memories / pylons are the same character as the viz hero, which is
// what makes the venue read as this track's place rather than a set.
if (module.venue) return actors.monolith || null;
if (!module.actor) return null;
return actors[module.actor] || null;
}
/** One Layer per (section, variant, layer slot), built lazily and kept. */
_layerFor(sectionIndex, variant, slot = 0) {
const spec = this._specFor(sectionIndex, variant, slot);
// The venue is the exception: one persistent layer for the whole song,
// not one per section. A per-section venue rebuilds the room at every
// boundary and dissolves old-room into new-room — two offset copies of
// the same furniture ghosting through each other, which reads as a
// glitch rather than a cut. See _venueLayer. (Hand-built looks with a
// venue module in a stack but no look.venue fall through to the normal
// path — persistence needs the track-level spec.)
if (spec.module && spec.module.venue && this.look && this.look.venue && this.look.venue.module) {
return this._venueLayer();
}
const key = `${sectionIndex}:${variant}:${slot}`;
let layer = this.layerCache.get(key);
if (!layer) {
const spec = this._specFor(sectionIndex, variant, slot);
const actorSpec = this._actorFor(spec.module);
const venueSpec = spec.module && spec.module.venue ? this._venueFor() : null;
layer = createLayer(spec.module, {
params: spec.params,
seed: spec.seed,
opacity: spec.opacity,
blend: spec.blend,
actorSpec,
venueSpec,
});
// For venue grounds, also bind venueSpec as actorSpec-like for geometry
layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality);
// ModelLayers can have their actor swapped without being rebuilt — the
// mesh is imposter-free so the geometry can be re-bound live.
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
if (venueSpec && layer.setVenue) layer.setVenue(venueSpec);
this.layerCache.set(key, layer);
}
return layer;
}
/**
* The venue layer: exactly one per ArcDriver, living for the whole song.
*
* Its params and seed come from the track-level venue.params sampled in
* generateLook — never re-sampled per section — so the furniture layout is
* the layout the song opened with. Per-section evolution arrives only as
* venueState (interpolated rest values, see _venueStateAt) plus the
* arrangement salt (see venueJumpAt): same objects, new dressing, and only
* ever with a camera cut to cover the swap.
*
* Keyed 'venue' in the layer cache, outside the section:v:slot namespace,
* so invalidateSection can never dispose it out from under a crossfade.
*/
_venueLayer() {
let layer = this.layerCache.get('venue');
if (!layer) {
const venue = this._venueFor();
const spec = this._venueSpec();
const actorSpec = this._actorFor(venue.module);
layer = createLayer(venue.module, {
params: (venue && venue.params) || (spec && spec.params) || {},
seed: (venue && Number.isInteger(venue.seed) ? venue.seed : null)
?? (spec && spec.seed) ?? 1,
opacity: 1,
blend: 'normal',
actorSpec,
venueSpec: venue,
});
layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality);
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
if (venue && layer.setVenue) layer.setVenue(venue);
this.layerCache.set('venue', layer);
}
return layer;
}
/** The ground-slot spec of the first section that stands on the venue. */
_venueSpec() {
for (const section of this.look.sections || []) {
for (const stack of section.variants || [section.layers]) {
const at = (stack || []).findIndex((l) => l && l.module && l.module.venue);
if (at >= 0) return stack[at];
}
}
return null;
}
/**
* Per-param LFO plan for a section: amplitude, period and phase, all seeded.
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
@@ -267,6 +383,10 @@ export class ArcDriver {
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG
// Continuity rule (see schema.js isDiscreteParam): a drifting count
// pops members mid-shot. Discrete params hold for the whole cue and
// change only at cuts, via the per-section/variant sample.
if (isDiscreteParam(def)) continue;
const [lo, hi] = def.range || [0, 1];
plan.push({
name,
@@ -401,10 +521,28 @@ export class ArcDriver {
const p = Math.max(0, Math.min(1, time / duration));
journey = p * p * (3 - 2 * p);
}
// Discrete params step their slow-axis travel at cuts instead of
// sliding it: the section-held journey moves only at section
// boundaries (which are always cuts), and without a story the cue's
// own start frame is the step. Either way nothing pops mid-shot.
// See params/schema.js isDiscreteParam.
let journeyStepped = null;
const steppedJourney = () => {
if (journeyStepped !== null) return journeyStepped;
if (this.look.story) {
journeyStepped = storyForSection(this.look.story, cue.sectionIndex).journey;
} else {
const duration = Math.max(1e-6, this.track.duration);
const pc = Math.max(0, Math.min(1, (cue.startFrame / this.track.fps) / duration));
journeyStepped = pc * pc * (3 - 2 * pc);
}
return journeyStepped;
};
for (const item of this._slowAxisFor(spec.module)) {
const base = out[item.name];
if (typeof base !== 'number') continue;
out[item.name] = clampValue(item.def, base + item.travel * (journey - 0.5));
const j = isDiscreteParam(item.def) ? steppedJourney() : journey;
out[item.name] = clampValue(item.def, base + item.travel * (j - 0.5));
}
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
@@ -429,6 +567,7 @@ export class ArcDriver {
const target = next.params;
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
if (isDiscreteParam(def)) continue;
if (typeof target[name] !== 'number') continue;
out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope);
}
@@ -437,6 +576,7 @@ export class ArcDriver {
// of their range so the build still reads as a build.
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (typeof out[name] !== 'number') continue;
if (isDiscreteParam(def)) continue;
if (def.bias !== 'energy' && def.bias !== 'density') continue;
const hi = (def.range || [0, 1])[1];
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
@@ -447,6 +587,92 @@ export class ArcDriver {
return out;
}
/**
* Base params for the persistent venue layer at a given time.
*
* The base is the track-level venue.params — one draw for the whole song —
* plus the same slow axis and drift every other layer gets. Deliberately
* NOT the per-section ground spec: re-sampling the room per section is the
* furniture swap this change kills. Drift and slow axis use a fixed
* section/variant/slot address (0:0:0) so the plan is stable no matter
* which cue is on screen; the values are continuous in time, so nothing
* jumps at a boundary. Lookahead is skipped — the room anticipates nothing,
* it arrives (see _venueStateAt).
*/
_venueParamsAt(cue, time, features, story = null) {
const venue = this.look && this.look.venue;
const base = (venue && venue.params) || {};
const out = { ...base };
const module = venue ? venue.module : null;
if (!module) return out;
let journey;
if (story) journey = story.journey;
else {
const duration = Math.max(1e-6, this.track.duration);
const p = Math.max(0, Math.min(1, time / duration));
journey = p * p * (3 - 2 * p);
}
for (const item of this._slowAxisFor(module)) {
const b = out[item.name];
if (typeof b !== 'number') continue;
out[item.name] = clampValue(item.def, b + item.travel * (journey - 0.5));
}
for (const item of this._driftPlan(0, 0, 0)) {
const b = out[item.name];
if (typeof b !== 'number') continue;
const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
out[item.name] = clampValue(item.def, b + wave * item.amplitude);
}
void cue; void features;
return out;
}
/**
* Framing for the persistent venue layer: the room's own shot, not the
* viz's. Served from the per-cue venue shot plan (see _planVenueShots):
* each cue eases to its own mark — size steps and look targets with a
* motive — instead of holding one framing for the whole song. A story jump
* still cuts instantly to the new arrangement's side (the furniture
* swapped; an eased move would glide through the old layout). Pure f(cue,
* frame) like _framingAt. No plan (no venue, hand-built look) → neutral.
*/
_venueFramingAt(cueIndex, frame) {
const cue = this.cues[cueIndex];
const jump = this.venueJumpAt(cue);
// Marks are per-cue for a dense look path, but size/target hold across
// each section's phrases and step at story beats. The dolly eases over
// the cue so size cuts read AS cuts; the look travels by progress.
if (this.venueShots && this.venueShots.length && cue) {
const frames = this.track.frameCount || 1;
const progress = frames > 1 ? frame / (frames - 1) : 0;
const shot = venueShotAt(this.venueShots, cueIndex, frame,
cue.startFrame, cue.endFrame, progress);
if (shot) {
return {
size: `venue-${shot.size}`,
scale: 1,
shift: [0, 0],
venueShot: shot,
venueJump: jump ? jump.arrangement : null,
};
}
}
// Fallback: the old hold/jump grammar (also what covers hand-built
// looks that never got a plan).
if (jump) {
const h = ((this.look.seed || 1) ^ Math.imul(jump.arrangement + 1, 0x9e3779b9)) >>> 0;
const side = (h % 2 === 0) ? 1 : -1;
return {
size: 'venue-jump',
scale: 1,
shift: [side * 0.55, ((h >> 8) % 100) / 100 * 0.2 - 0.1],
venueJump: jump.arrangement,
};
}
return { size: 'venue-hold', scale: 1, shift: [0, 0] };
}
/**
* The buildSlope value on the frame before a boundary. Read from the table
* rather than remembered, so a seek and playback agree.
@@ -506,10 +732,12 @@ export class ArcDriver {
// During a blend transition, lerp from the previous cue's palette to this
// one's over the cue's fadeFrames, in OKLCH so hue travel stays perceptual.
// Sharp transitions never blend: on a hard cut the colour jumps
// instantly with the image, even under a blend-mode director.
let base = palettes[baseIndex] || palettes[0];
let blendT = 0;
let blendFrom = -1;
if (plan && plan.transition === 'blend' && cue && cueIndex > 0) {
if (plan && plan.transition === 'blend' && cue && cueIndex > 0 && !cue.hardCut) {
const prevIndex = plan.cues[cueIndex - 1];
if (prevIndex !== baseIndex) {
const into = frame - cue.startFrame;
@@ -650,6 +878,28 @@ export class ArcDriver {
const layers = [];
// The venue is one continuous place, not a per-section image: the
// persistent venue layer carries over across every boundary, and is
// NEVER part of the outgoing/incoming dissolve. Dissolving old-room
// into new-room is the glitch this change kills — two offset copies of
// the same furniture ghosting through each other. The room drifts via
// venueState; it only ever jumps with a camera cut (see venueJumpAt).
const venueJump = this.venueJumpAt(cue);
const venueState = (this.look && this.look.venue) ? this._venueStateAt(frame) : null;
const pushVenue = (spec, layer, params, venueFraming, opacity) => {
layer.setParams(params);
layer.opacity = opacity;
layer.blend = 'normal';
layer.setPalette(palette);
layer.setPersonality(groundPersonalityFrom(personality));
layer.setFraming(venueFraming);
layer.venueState = venueState;
// Arrangement salt: same objects, new dressing — applied on the
// jump frame itself, under the camera cut. Null most of the time.
layer.venueArrangement = venueJump ? venueJump.arrangement : null;
layers.push(layer);
};
if (fading) {
// buildSlope is discontinuous at a section boundary by construction:
// it ramps to ~1 through the bars before the change and is 0
@@ -665,6 +915,10 @@ export class ArcDriver {
for (let slot = 0; slot < this._stackSize(previous); slot++) {
const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
// Venue slot of the outgoing stack: skip it here, the persistent
// layer is pushed once below. (Its pre-jump dressing, if any, is
// already behind us — the jump lands on the cue frame.)
if (spec && spec.module && spec.module.venue) continue;
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
layer.opacity = slot === 0 ? 1 : spec.opacity;
@@ -676,9 +930,17 @@ export class ArcDriver {
}
}
// Incoming stack. Slot 0 is the venue ground when the venue is on: the
// persistent layer takes its place at full opacity, undissolved. Every
// other slot dissolves in over it exactly as before.
const venueFraming = this._venueFramingAt(cueIndex, frame);
for (let slot = 0; slot < this._stackSize(cue); slot++) {
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
if (spec && spec.module && spec.module.venue) {
pushVenue(spec, layer, this._venueParamsAt(cue, time, features, story), venueFraming, 1);
continue;
}
layer.setParams(this._paramsAt(cue, slot, time, features, story));
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
layer.blend = slot === 0 ? 'normal' : spec.blend;
@@ -707,12 +969,95 @@ export class ArcDriver {
tension: story.tension,
reveal: story.reveal,
journey: story.journey,
venueName: this.look && this.look.venue ? this.look.venue.name : null,
venueArchetype: this.look && this.look.venue ? this.look.venue.archetype : null,
venueState,
venueShot: venueFraming && venueFraming.venueShot ? {
size: venueFraming.venueShot.size,
target: venueFraming.venueShot.target,
} : null,
};
this.activeLayers = layers;
return layers;
}
/**
* Per-venue story state at frame — INTERPOLATED between section rests.
*
* The old version stepped discretely at each boundary, so the room's light,
* fog and population all jumped on the same frame the viz cut. The room
* must drift while the viz cuts: rest values come from
* VenueSpec.perSection, and the first half of each section eases from the
* previous section's rest to this one's. Pure f(frame) — a seek lands on
* the same blend as playback, and the midpoint of a long section is fully
* arrived (no endless slide).
*/
_venueStateAt(frame) {
const venue = this.look && this.look.venue;
if (!venue || !venue.perSection || !venue.perSection.length) return null;
const idx = this._venueSectionIndexAt(frame);
const cur = venue.perSection[idx] || venue.perSection[0];
if (idx <= 0) return { ...cur };
const prev = venue.perSection[idx - 1] || cur;
const sec = (this.look.sections || [])[idx];
let t = 1;
if (sec && sec.endFrame > sec.startFrame) {
// Arrive over the first half of the section, hold the rest.
t = Math.max(0, Math.min(1, (frame - sec.startFrame) / ((sec.endFrame - sec.startFrame) * 0.5)));
t = t * t * (3 - 2 * t);
}
const out = {};
for (const k of Object.keys(cur)) {
const a = prev[k], b = cur[k];
out[k] = (typeof a === 'number' && typeof b === 'number') ? a + (b - a) * t : b;
}
return out;
}
/** Section index owning a frame, for venue state and jumps. */
_venueSectionIndexAt(frame) {
const venue = this.look && this.look.venue;
const n = (venue && venue.perSection && venue.perSection.length)
|| (this.look.sections || []).length;
const story = this.look.story;
if (story && story.frames && story.frames.length) {
for (let i = Math.min(story.frames.length, n) - 1; i >= 0; i--) {
if (frame >= story.frames[i].startFrame) return Math.min(i, n - 1);
}
return 0;
}
const secs = this.look.sections || [];
for (let i = Math.min(secs.length, n) - 1; i >= 0; i--) {
const sec = secs[i];
if (frame >= sec.startFrame && frame < sec.endFrame) return Math.min(i, n - 1);
}
return 0;
}
/**
* Whether the venue rearranges on this cue, and which arrangement is live.
*
* Returns null when the room only drifts (the common case): furniture
* carries over untouched. Returns {arrangement} at a section-start cue
* whose rearrangeAt value differs from the previous section's — the room
* re-hangs itself, and the caller MUST pair it with a camera cut
* (see _venueFramingAt / Camera.relocatesAt), because an uncovered
* furniture swap is the glitch this whole change exists to kill.
*
* Pure f(cue): arrangement ids come from VenueSpec.rearrangeAt, decided at
* generation time. The outro returns to arrangement 0 (the intro's room).
*/
venueJumpAt(cue) {
const venue = this.look && this.look.venue;
if (!venue || !venue.rearrangeAt || !venue.rearrangeAt.length) return null;
if (!cue || !cue.atSectionStart || cue.sectionIndex <= 0) return null;
const before = venue.rearrangeAt[cue.sectionIndex - 1] ?? 0;
const at = venue.rearrangeAt[cue.sectionIndex] ?? before;
if (at === before) return null;
return { arrangement: at, from: before };
}
/** Which slot of a cue's stack is the shot. See look/stack.js. */
_subjectSlot(cue) {
return subjectIndexOf(this._stackFor(cue));
@@ -736,6 +1081,9 @@ export class ArcDriver {
/** Invalidate caches for one section after an edit or reroll. */
invalidateSection(sectionIndex) {
for (const key of [...this.layerCache.keys()]) {
// The venue layer ('venue') outlives every section: a reroll
// re-casts the section's viz, never the room it stands in.
if (key === 'venue') continue;
if (key.startsWith(`${sectionIndex}:`)) {
this.layerCache.get(key).dispose();
this.layerCache.delete(key);
@@ -746,6 +1094,7 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planVenueShots();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
@@ -757,6 +1106,7 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planVenueShots();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
@@ -777,6 +1127,12 @@ export class ArcDriver {
if (onLayer) onLayer(layer);
}
}
// The persistent venue layer is built once here so the first frame
// never pays for its geometry.
if (this.look && this.look.venue && this.look.venue.module) {
const layer = this._venueLayer();
if (onLayer) onLayer(layer);
}
return this;
}
@@ -821,6 +1177,11 @@ export class ArcDriver {
return this._framingAt(this._cueIndexAt(frame), frame);
}
/** The room's own framing for a frame — holds across cuts, jumps on story jumps. */
venueFramingForFrame(frame) {
return this._venueFramingAt(this._cueIndexAt(frame), frame);
}
personalityForFrame(frame, story) {
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
return this._personalityAt(s);
+10 -3
View File
@@ -279,9 +279,11 @@ function timingFor(spanFrames, story, energy, camera, curve, rng) {
*/
function relocatesAt(cue, story, rng) {
if (!cue.hardCut && cue.atSectionStart) return true; // a new section is a new place
// A sharp transition ALWAYS cuts the camera: the image swaps, so the gaze
// must land somewhere new with it — never a static hold across a hard cut.
if (cue.hardCut) return true;
const tension = story ? story.tension : 0.5;
let chance = 0.12 + tension * 0.45;
if (cue.hardCut) chance += 0.3;
if (story) {
if (story.act === 'climax') chance += 0.2;
else if (story.act === 'resolution') chance *= 0.4;
@@ -303,9 +305,13 @@ function relocatesAt(cue, story, rng) {
* @param {Array} sections look.sections, for bias and story state
* @param {object} camera from deriveCamera
* @param {Rng} rng
* @param {Set} [forceRelocateAt] cue indexes that MUST relocate the gaze —
* venue story jumps (see ArcDriver.venueJumpAt). The room re-hangs on those
* frames, so the viz camera cuts with it: two cuts landing together read as
* one edit, while a room swap under a walking camera reads as a glitch.
* @returns {Array} one move per cue, index-aligned
*/
export function planGaze(cues, sections, camera, rng) {
export function planGaze(cues, sections, camera, rng, forceRelocateAt = null) {
const moves = [];
let at = [0, 0];
let previousDir = null;
@@ -329,7 +335,8 @@ export function planGaze(cues, sections, camera, rng) {
// snap this causes lands exactly on a cut, which is where the eye is
// least able to see it.
let from = clampToReach(at, reach);
if (moves.length && relocatesAt(cue, story, rng)) {
if (moves.length && (relocatesAt(cue, story, rng)
|| (forceRelocateAt && forceRelocateAt.has(cue.index)))) {
const hop = jumpFor(story, !!cue.hardCut, rng) * reach;
const jumped = targetFor(from, hop, camera, story, rng, previousDir);
from = clampToReach(jumped.point, reach);
+234 -18
View File
@@ -21,11 +21,17 @@ import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import {
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
} from './directors.js';
import {
agitationOf, agitationWeight, comfortCeilingFor, stackAgitation,
} from './comfort.js';
import { filterScenesSync } from './scene-filter.js';
import { validateSection } from './composition-validator.js';
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js';
import { generateVenue, describeVenue } from '../venues/VenueGenerator.js';
// Which families suit which section kind now comes from the track's DIRECTOR
// (look/directors.js) rather than from a constant here. The coupling it
@@ -243,13 +249,21 @@ function castingPool(rng, signature, size = POOL_SIZE) {
return picked;
}
let _activeVenueFamily = null; // set per-track for venue-family lean
function candidatesForKind(kind, used, signature = [], director, pool = null) {
const families = director.families[kind] || Object.keys(FAMILIES);
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
// Vast preliminary library: hard filter BEFORE the lean. Reads ranged
// `agitation.max / structPeak.max` (SCHEMA 7) and per-kind comfort;
// some songs simply won't use certain scenes. Lean still applies after.
const eligibleSet = new Set(filterScenesSync(scenes.filter(canBackground), { director, sectionKind: kind }).map((m) => m.name));
// Preliminary comfort ceiling for this kind (base appetite, before per-section
// story nudge). Filter already excluded the hottest; lean penalises the edge.
const comfortCeiling = comfortCeilingFor(director);
const candidates = [];
for (const family of families) {
const inFamily = scenesInFamily(family)
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)));
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)) && eligibleSet.has(m.name));
// Weight by family preference order, and push down anything already
// used so a five-section track doesn't show one scene five times.
const weight = families.length - families.indexOf(family);
@@ -259,16 +273,24 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
// The signature is a lean now, not a wall. See
// Personality.signatureWeight for why it had to stop being one.
weight: weight * signatureWeight(scene, signature)
* (used.has(scene.name) ? 0.15 : 1),
* (used.has(scene.name) ? 0.15 : 1)
* agitationWeight(scene, comfortCeiling)
* (_activeVenueFamily && scene.family === _activeVenueFamily ? 0.55 : 1),
});
}
}
if (!candidates.length) {
// This track's pool holds nothing in the families the director wants for
// this kind. Widen to the pool, then to the library — the track keeps
// its scenes either way.
// Empty after filter — widen once via filterScenesSync's 1.15× ceiling,
// else fall back to pool→library so a section is never empty; validator
// still rejects if composite hot and re-rolls.
const byFilter = filterScenesSync(scenes.filter(canBackground), { director, sectionKind: kind });
const byFilterInPool = byFilter.filter((m) => !allowed || allowed.has(m.name));
const found = byFilterInPool.length ? byFilterInPool : byFilter;
if (found.length) {
return found.map((scene) => ({ scene, weight: signatureWeight(scene, signature) * agitationWeight(scene, comfortCeiling) }));
}
const fallback = (pool && pool.length) ? pool : scenes.filter(canBackground);
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) * agitationWeight(scene, comfortCeiling) }));
}
return candidates;
}
@@ -363,6 +385,11 @@ function castGround(kind, roster, rng, signature, director, used) {
// and it is worse underneath than on top because nothing hides it.
if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
w *= signatureWeight(m, signature);
// Whole-screen comfort: a ground that itself is hot (worst 1 s window) is
// felt for the entire section — it is underneath everything. Penalise it
// more steeply than a shot, because a shot is cut away from and a ground
// is not.
w *= agitationWeight(m, comfortCeilingFor(director));
// Overshooting the budget is allowed and discouraged: the ground is
// mandatory, so an oversized one is spent frame the shot cannot use.
w /= 1 + 4 * Math.max(0, sceneCoverage(m) - headroom);
@@ -594,9 +621,11 @@ function contrastWeight(candidate, stack) {
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
{ ground = null, director = null, kind = null } = {}) {
const sectionKind = kind;
// A shot that fills the frame by itself is its own ground; anything else
// gets one under it.
const standsAlone = canGround(module) || !ground;
// Venue is always the ground — even a canvas shot stands on its venue.
// (canGround(venue) is false by design, so the old rule would think a
// canvas viz needs no ground and drop the venue.)
const isVenueGround = !!(ground && ground.venue);
const standsAlone = !isVenueGround && (canGround(module) || !ground);
// --- the blaze ------------------------------------------------------
// Whether THIS section is one the director lets bloom out: the shot added
@@ -609,17 +638,33 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
// the director's appetite, times a loud section, times a late point in the
// story. Quiet kinds never blaze; a breakdown that goes white is not a
// decision, it is a bug with a rationale.
// A venue rates its own reach: the chamber's dim plaster earns the blaze
// often, the stage's 14-unit emissive wall rarely. Defaults to 1 (the old
// behaviour) for scene grounds, which carry no rating.
const groundReach = isVenueGround && ground.reach !== undefined ? ground.reach : 1;
const blaze = !standsAlone
&& !QUIET_KINDS.includes(sectionKind)
&& rng.bool(blazeOf(director) * clamp01(bias.energy * 1.2)
&& rng.bool(blazeOf(director) * groundReach * clamp01(bias.energy * 1.2)
* (story ? 0.4 + story.tension * 0.9 : 0.7));
// The shot is sampled first and from the caller's rng, so a stack draws the
// same shot it always did and the ground arrives underneath it rather than
// in front of it in the seed stream.
//
// Comfort: on quiet kinds (intro/breakdown/outro) a hot shot is down-weighted
// at roster construction (candidatesForKind) and here we try a single
// param re-sample on a fresh fork before committing. The per-scene meter is
// preliminary — per-param agitation is not measured — so one attempt is the
// most this level can do; the roster weighting is the real lever. See
// look/comfort.js for the ceiling and "different scene or settings" handling.
let shotParams = sampleValues(module, rng, bias, temperament);
if (QUIET_KINDS.includes(sectionKind) && agitationOf(module) > comfortCeilingFor(director, story)) {
const fork = rng.fork(`comfort:${module.name}`);
shotParams = sampleValues(module, fork, bias, temperament);
}
const shot = {
module,
params: sampleValues(module, rng, bias, temperament),
params: shotParams,
seed: rng.int(0, 0x7fffffff),
// Keyed over the ground on its own brightness by default: what the shot
// leaves unpainted is then the ground rather than black, which is the
@@ -731,7 +776,24 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
// with something happening in it. See scenes/surface.js.
const offFamily = available.filter((m) => m.family !== module.family);
const pool = offFamily.length ? offFamily : available;
const overlay = rng.pickWeighted(pool, pool.map((m) => contrastWeight(m, layers)));
// Comfort lean: an overlay that is itself hot, or that would push the
// stack over the ceiling when added, is down-weighted. Whole-screen is
// reserved for special cases — a calm shot with a hot shimmer over it
// exceeds the floating-window promise just as surely as a hot shot alone.
const comfortCeiling = comfortCeilingFor(director, story);
const overlay = rng.pickWeighted(pool, pool.map((m) => {
let w = contrastWeight(m, layers);
w *= agitationWeight(m, comfortCeiling);
// Stack-level proxy: adding this overlay on top of what is already
// on screen. Two moderately agitated layers together count a bit more
// than either alone, so check the combined proxy as well.
const combined = stackAgitation([...layers, { module: m }]);
if (combined > comfortCeiling) {
const excess = Math.max(0, Math.min(1, (combined - comfortCeiling) / (1 - comfortCeiling)));
w *= Math.max(0.08, 1 - excess * 0.85);
}
return w;
}));
spent += sceneCoverage(overlay);
available = available.filter((m) => m.name !== overlay.name
&& m.family !== overlay.family);
@@ -767,6 +829,7 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
export function generateLook(track, {
seed = null, samples = null, overrides = null,
pool: poolOverride = null, poolSize = POOL_SIZE,
venueEnabled = false,
} = {}) {
const resolvedSeed = seed !== null
? seed >>> 0
@@ -805,8 +868,38 @@ export function generateLook(track, {
// section, and whether the outro answers the intro. See look/Story.js.
const story = deriveStory(track, summary, rng.fork('story'));
// Venue — the place the track lives. Higher-level than viz, picked first.
// Behind flag in Phase 0 so existing videos/Gates stay green.
//
// The venue's render params are sampled ONCE per track, not per section:
// the room is one continuous place, so its density/light/etc. must be one
// continuous draw. Per-section evolution arrives separately as venueState
// (interpolated rest values, see ArcDriver._venueStateAt), never as a
// re-sample — re-sampling per section is what used to swap the furniture
// out from under the camera at every boundary.
let venue = null;
if (venueEnabled) {
try {
venue = generateVenue({
summary, rng: rng.fork('venue'),
personality, director, story,
sections: track.sections,
});
if (venue && venue.module) {
const vRng = rng.fork(`venue:params:${venue.name}`);
const vBias = { ...biasFor(track.sections[0], summary, personality.motion, storyForSection(story, 0)), energy: 0.55, density: 0.55 };
venue.params = sampleValues(
venue.module, vRng, groundBiasFrom(vBias),
groundTemperamentFrom(personality.temperament));
venue.seed = vRng.int(0, 0x7fffffff);
}
} catch (e) { venue = null; }
}
_activeVenueFamily = venue ? venue.module.family : null;
const rosterByKind = assignRostersByKind(
track.sections, rng.fork('scenes'), personality.signature, director, pool);
_activeVenueFamily = null;
if (story.recap) applyRecap(rosterByKind);
// What each kind's sections stand on. After the recap, so the outro is
// grounded against the roster it actually ends up with.
@@ -853,7 +946,14 @@ export function generateLook(track, {
// so the last occurrence of a kind samples further out than the first.
const temperament = temperamentFor(personality.temperament, state);
const ground = groundByKind.get(section.kind) || null;
// When the venue is on it IS the ground for every section — one place,
// one module. The ArcDriver renders it as a single persistent layer
// (see _venueLayer), so the per-section ground spec below is only the
// stack slot the venue occupies; its params/seed come from the
// track-level venue.params sampled above, not from here.
const ground = (venueEnabled && venue && venue.module)
? venue.module
: (groundByKind.get(section.kind) || null);
const variants = roster.map((module, v) => buildStack(
module, overlayRoster, bias,
@@ -887,6 +987,8 @@ export function generateLook(track, {
const look = {
seed: resolvedSeed,
venue,
venueEnabled,
palette,
// The full set the director cuts between — ArcDriver reads this per cue.
palettes,
@@ -906,6 +1008,112 @@ export function generateLook(track, {
summary,
};
// Validate + re-roll — director rejects a section whose predicted
// agitation exceeds the desired ceiling for its kind (kindFactor 0.38–0.72,
// murk-like stillness + flatness pull down further; composite penalty +
// 0.28 ground+shot canvases). Up to MAX attempts: (1) drop overlay,
// (2) swap overlay for composable <0.30, (3) reselect shot variant filtered
// by ranged metrics. Validator may refuse a section (> desired+0.22) and if
// ≥2 sections refuse the song refuses to render (validateLook.songRefused).
// Deterministic via forked rng; never empty (keep quietest). See
// src/look/composition-validator.js + src/look/scene-filter.js.
const MAX_VALIDATE_ATTEMPTS = 6;
const compositionRefused = [];
for (const sec of sections) {
const state = sec.story || null;
let bestLayers = sec.layers;
let bestScore = validateSection(sec, { director, story: state, summary });
if (bestScore.ok) continue;
let bestPredicted = bestScore.predicted;
// Try to retune this section's anchor stack (variants[0] == layers)
for (let attempt = 1; attempt <= MAX_VALIDATE_ATTEMPTS; attempt++) {
const tuneRng = rng.fork(`validator:${sec.index}:${attempt}`);
const temper = temperamentFor(personality.temperament, state);
const curLayers = sec.layers;
let candidateLayers = null;
if (attempt === 1) {
// 1) Drop overlays — ground + shot only
const ground = curLayers.find((l) => l.role === 'ground') || null;
const shot = curLayers.find((l) => !l.role || l.role !== 'ground') || curLayers[0];
candidateLayers = ground ? [ground, shot] : [shot];
} else if (attempt === 2) {
// 2) Swap overlay for a quiet composable <0.30 coverage
const ground = curLayers.find((l) => l.role === 'ground') || null;
const shot = curLayers.find((l) => !l.role || l.role !== 'ground') || curLayers[0];
const baseLayers = ground ? [ground, shot] : [shot];
// Pick a quiet composable filtered by scene-filter
const pool = scenes.filter((m) => canBackground(m) || m.kind === 'layer3d');
const quietCandidates = filterScenesSync(pool, { director, sectionKind: sec.kind });
// Use coverage + agitation to pick quietest composable
const composables = quietCandidates.filter((m) => {
try { return !canGround(m); } catch { return false; }
});
const quietPool = composables.length ? composables : quietCandidates;
let bestOverlay = null;
let bestAgit = Infinity;
for (const cand of quietPool) {
// Prefer low coverage + low agitation
const cov = (() => { try { return sceneCoverage(cand); } catch { return 1; } })();
if (cov > 0.30) continue;
const a = agitationOf(cand);
if (a < bestAgit) { bestAgit = a; bestOverlay = cand; }
}
if (bestOverlay) {
const ov = {
module: bestOverlay,
params: sampleValues(bestOverlay, tuneRng, sec.bias, temper),
seed: tuneRng.int(0, 0x7fffffff),
blend: tuneRng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]),
opacity: tuneRng.range(0.22, 0.45),
};
candidateLayers = [...baseLayers, ov];
} else {
candidateLayers = baseLayers;
}
} else {
// 3) Reselect shot variant from roster filtered by ranged metrics
const kindRoster = rosterByKind.get(sec.kind) || [scenes[0]];
const filtered = filterScenesSync(kindRoster, { director, sectionKind: sec.kind });
const pickPool = filtered.length ? filtered : kindRoster;
const picked = tuneRng.pick(pickPool);
const groundLayer = curLayers.find((l) => l.role === 'ground') || null;
const groundModule = groundLayer ? groundLayer.module : null;
candidateLayers = buildStack(
picked, overlayRoster, sec.bias,
tuneRng, temper, state,
{ ground: groundModule, director, kind: sec.kind },
);
}
// Score candidate (pass summary so murk-like stillness lowers desired)
const fakeSection = { ...sec, layers: candidateLayers };
const candScore = validateSection(fakeSection, { director, story: state, summary });
if (candScore.ok) {
sec.layers = candidateLayers;
sec.variants = [candidateLayers, ...sec.variants.slice(1)];
bestScore = candScore;
bestLayers = candidateLayers;
break;
}
if (candScore.predicted < bestPredicted) {
bestPredicted = candScore.predicted;
bestLayers = candidateLayers;
bestScore = candScore;
}
}
// Keep quietest attempt (never empty); record refusal for song-level fail
if (bestLayers !== sec.layers) {
sec.layers = bestLayers;
sec.variants[0] = bestLayers;
}
if (bestScore.refused) compositionRefused.push(sec.index);
}
if (compositionRefused.length >= 2) {
const err = new Error(`high agitation: director refused ${compositionRefused.length} sections (${compositionRefused.join(',')}) — predicted > desired+0.22`);
err.code = 'COMPOSITION_REFUSED';
err.refused = compositionRefused.slice();
throw err;
}
return overrides ? applyOverrides(look, overrides) : look;
}
@@ -926,12 +1134,15 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
// Re-roll the whole roster, not just the anchor: the section's shots cut
// between all of them, so replacing one would leave the section half old.
// Respect comfort — a hot scene stays less likely even on reroll, but is not
// excluded so the user can still land on it if the pool is thin.
const rerollCeiling = comfortCeilingFor(director);
const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
const roster = [];
while (roster.length < size) {
const options = candidates.filter((m) => !roster.includes(m));
if (!options.length) break;
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature))));
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature) * agitationWeight(m, rerollCeiling))));
}
if (!roster.length) roster.push(scenes[0]);
@@ -942,8 +1153,11 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
const state = section.story || NEUTRAL_STATE;
// The section is re-cast, so its ground is re-cast with it — a reroll that
// kept the old bed under new shots would be answering half the question.
const ground = castGround(
section.kind, roster, rng.fork('ground'), signature, director, new Set());
// When venue is enabled the venue IS the ground and must not be re-cast per section.
const isVenue = !!(look.venueEnabled && look.venue && look.venue.module);
const ground = isVenue
? look.venue.module
: castGround(section.kind, roster, rng.fork('ground'), signature, director, new Set());
section.variants = roster.map((module, v) => buildStack(
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
temperamentFor(look.personality && look.personality.temperament, state), state,
@@ -961,7 +1175,7 @@ export function rerollLook(look, track, newSeed) {
const locked = new Map();
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
const next = generateLook(track, { seed: newSeed >>> 0 });
const next = generateLook(track, { seed: newSeed >>> 0, venueEnabled: !!look.venueEnabled });
next.sections.forEach((s, i) => {
if (locked.has(i)) next.sections[i] = locked.get(i);
});
@@ -983,6 +1197,7 @@ function applyOverrides(look, overrides) {
look.palette = look.palettes[0] || look.palette;
}
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
if (overrides.venue !== undefined) look.venue = overrides.venue;
if (overrides.post) look.post = { ...look.post, ...overrides.post };
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
if (overrides.sections) {
@@ -1005,10 +1220,11 @@ export function describeLook(look) {
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: '';
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
const venueTag = look.venue ? ` · ${describeVenue(look.venue)}` : '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${describeCamera(look.camera)}${planTag}${actorTag} · ` +
`${describeCamera(look.camera)}${planTag}${actorTag}${venueTag} · ` +
`${[...new Set(kinds)].join(', ')}`;
}
+166
View File
@@ -0,0 +1,166 @@
// Comfort / agitation guard — the floating-window cap that keeps whole-screen
// flashing and coherent motion out of the places a viewer needs calm.
//
// The metric itself lives in engine/agitation.js (tile-based whole-screen motion
// + luminance flash, 1 s floating window, worst window wins, normalised to 0..1).
// This file is the composition policy: what the ceiling is, how it varies with
// director and story, and how the generator reads the measured per-scene value
// (scenes/metadata.json → agitation) and turns it into a weight.
//
// Whole-screen is reserved for special cases. Region-based motion is fine — a
// ribbon in the centre, particles, a small form turning — and scores low because
// only a few tiles are active. A full-frame pan/zoom or a uniform luminance pump
// lights most tiles at once and drives the score high. The director must choose
// a different scene or different settings when the cap would be exceeded, with a
// preliminary per-visualizer proxy at generation time (this file) and a later
// full-stack floating-window audit over the rendered Show.
//
// Configurable in one place: change the constants here and press "refresh
// metadata" in the gallery — the fingerprint covers agitation.js so staleness is
// detectable rather than silent.
import metadata from '../scenes/metadata.json';
import { AGITATION_CEILING } from '../engine/agitation.js';
// The checked-in ceiling the gallery and the scene gate enforce. Directors may
// be stricter or looser per point of view (see directors.js `comfort`), and the
// story may nudge it per section (tension). Absent, this is the promise.
export const DEFAULT_COMFORT_CEILING = AGITATION_CEILING;
// --- A: whole-screen beat/flux caps --------------------------------------
//
// Canvas scenes paint ≥50% of the frame (see scenes/surface.js GROUND_MIN).
// When a canvas swerves, shuffles or jumps with `u_beat` on a `spike`, the
// ENTIRE view moves in sync on every kick (centre/124 BPM ≈ 2 Hz). That is
// the nausea source on `?song=centre&seed=3837488838`: every mid-tempo track
// does it, and the per-scene `agitation` metric already flags it but
// selection-weighting alone cannot quiet a canvas already on screen. Only
// smaller regions/elements should react wildly — they light few tiles.
//
// These caps are applied centrally in engine/Layer.js:resolveParams so no
// per-shader edit is needed for the immediate relief, and lint-scenes.js warns
// when a declaration exceeds them. Tunable in one place; `npm run lint:scenes`
// is the feedback, and a changed cap makes metadata stale (see checks/metadata.js
// SOURCES) so `gallery → Refresh metadata` is required — the workflow already
// documented in gallery.html. Composable scenes (coverage < 0.50) are untouched.
export const CANVAS_BEAT_CAP = 0.15; // beat amount ceiling on canvas
export const CANVAS_FLUX_CAP = 0.25; // flux amount ceiling on canvas
export const CANVAS_BEAT_SOFTEN_SPIKE = true; // spike → smooth on canvas (same peak, less step)
// How far below the ceiling a scene still reads as calm vs. caution in the UI.
export const COMFORT_CAUTION = 0.50;
/**
* Measured agitation for a scene module (0..1, worst 1 s window over the
* gallery strip). Missing or not yet measured → 0 (unmeasured scenes must not
* be penalised before the first refresh, otherwise the roster collapses).
*
* This is a proxy — per-scene, not per-section, and at the gallery's busiest
* bias — so it is a lean, not a hard exclusion.
*/
export function agitationOf(module) {
if (!module || !module.name) return 0;
const row = metadata.scenes && metadata.scenes[module.name];
if (!row) return 0;
const v = row.agitation;
return typeof v === 'number' && isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
}
/**
* The ceiling that applies to a director in a given story state.
*
* `tension` 0..1 (Story.js) is how far into the arc this section sits. The
* climax tolerates slightly more whole-screen energy than an intro; the intro
* does not, and the quiet-kind coupling in LookGenerator tightens it further.
* Bounded so the knob never inverts the library — even `corrupt` at climax
* does not go above 0.95, and even `ambient` in an intro does not go below 0.30.
*
* `director.comfort` is the point-of-view appetite (absent → DEFAULT). Story
* null → base without the arc nudge, which is what roster construction uses
* before per-section story state exists.
*/
export function comfortCeilingFor(director, story = null) {
const base = director && director.comfort !== undefined
? director.comfort
: DEFAULT_COMFORT_CEILING;
if (!story) return clamp(base, 0.30, 0.95);
const tension = clamp(story.tension ?? 0.5, 0, 1);
// tension 0.5 → base, 1.0 → base*1.08, 0.0 → base*0.90
const factor = 0.90 + tension * 0.18;
return clamp(base * factor, 0.30, 0.95);
}
/** Director's own comfort appetite, without the story nudge. */
export function comfortOf(director) {
return director && director.comfort !== undefined
? director.comfort
: DEFAULT_COMFORT_CEILING;
}
/**
* Weight multiplier for a candidate scene given the ceiling that applies where
* it would be cast.
*
* Only scenes above the ceiling are penalised — a scene at 0.69 with a 0.70
* ceiling is not pushed down, which keeps the library from collapsing when most
* canvases sit near the line. Above it, the falloff is steep but never to zero:
* a hot scene stays reachable for a `corrupt` drop, just very unlikely for a
* quiet intro. This is the "must choose a different scene or settings" lever at
* generation time; the floating-window truth is checked over the rendered Show
* later (the per-visualizer value here is the preliminary tracking the brief
* asks for).
*
* agitation ≤ ceiling → 1.0
* ceiling < agitation < 1.0 → 1.0 → 0.05, linear in excess
* agitation ≥ 1.0 → 0.05 (floor, never excluded)
*/
export function agitationWeight(module, ceiling) {
const a = agitationOf(module);
if (!a || a <= ceiling) return 1;
const c = clamp(ceiling, 0.05, 0.95);
const excess = clamp((a - c) / (1 - c), 0, 1);
return Math.max(0.05, 1 - excess * 0.95);
}
/**
* Stack-level agitation proxy from the shot + overlays already chosen.
*
* At generation time we have no pixels — only per-scene proxies — so the
* stack's agitation is approximated as the max of its layers, with a small
* blend from the mean (two moderately agitated layers together are a bit more
* than either alone, but not double). Used to decide whether another overlay
* would push the section over the ceiling.
*/
export function stackAgitation(layers) {
if (!layers || !layers.length) return 0;
let max = 0;
let sum = 0;
for (const l of layers) {
const a = agitationOf(l.module);
if (a > max) max = a;
sum += a;
}
const mean = sum / layers.length;
return Math.max(max, max * 0.75 + mean * 0.25);
}
/**
* Ranged metric for a scene module: {mean,min,max,std,p10,p90} or null.
* Reads from metadata.json `metrics[key]` produced by gallery (SCHEMA 7).
* Cost-agnostic — covers agitation, motionPeak, structPeak, coverage, etc.
*/
export function metricRangeOf(module, key) {
if (!module || !module.name || !key) return null;
const row = metadata.scenes && metadata.scenes[module.name];
if (!row) return null;
if (row.metrics && row.metrics[key]) return row.metrics[key];
// Fallback to legacy flat value → treat as mean-only range
const flatMap = { agitation: row.agitation, flashPeak: row.flashPeak, motionPeak: row.motionPeak, structPeak: row.structPeak, coverage: row.coverage };
const v = flatMap[key];
if (typeof v === 'number' && Number.isFinite(v)) return { mean: v, min: v, max: v, std: 0, p10: v, p90: v };
return null;
}
function clamp(x, lo, hi) {
return Math.max(lo, Math.min(hi, x));
}
@@ -0,0 +1,113 @@
// Composition validator — per-section check with re-roll + tune.
//
// After `generateLook` has chosen rosters, ground and variants, the actual
// stacks (ground+shot+0-2 overlays) exist. The director's desired agitation per
// section kind is `comfortCeilingFor(director, story) * kindFactor * stillnessAdj`.
// `stillnessAdj` pulls the ceiling down for still, dark-noisy material (murk).
// Predicted agitation is `stackAgitation([...layers])` (max-of-3-arms over the
// 3-arm Rev 2 agitation) PLUS composite penalties for wall-to-wall canvases —
// without this the proxy 0.21 for two canvases reads calm while the eye sees
// swirl. If predicted exceeds desired the section is *rejected* and the
// director must re-build it (re-roll + tune) until it passes, or the section
// is marked refused. If too many sections refuse, the song refuses to render.
// Tunable via `look/comfort.js` ceilings; no rendering here (uses preliminary
// per-scene maxima, now 12+ ranged metrics). A future pass can render a 60-frame
// composite burst for exact validation if needed.
import { comfortCeilingFor, stackAgitation } from './comfort.js';
import { surfaceOf } from '../scenes/surface.js';
const KIND_DESIRED_FACTOR = {
intro: 0.38,
build: 0.62,
drop: 0.72,
sustain: 0.68,
breakdown: 0.38,
outro: 0.42,
};
function kindFactor(kind) {
return KIND_DESIRED_FACTOR[kind] ?? 0.65;
}
function desiredFor(director, story, kind, summary = null) {
const base = comfortCeilingFor(director, story);
let v = Math.min(0.95, base * kindFactor(kind));
// Murk-like: still, dark-noisy material should be calm even in its drop.
// When a track is noisy and bright-hiss, the director wanted overload but
// the material wants a static bed + floaters — pull the ceiling down.
// (Personality.motion.stillness is not wired through summary; the flat/
// centroid heuristic is the only signal available here.)
if (summary) {
const flat = summary.meanFlatness ?? 0;
const centroid = summary.meanCentroid ?? 0.5;
const isMurkLike = flat > 0.55 && centroid > 0.75;
if (isMurkLike) v *= 0.72;
}
// Story tension still nudges via comfortCeilingFor, but clamp low for calm kinds
if (kind === 'intro' || kind === 'breakdown' || kind === 'outro') v = Math.min(v, 0.45);
return Math.max(0.18, Math.min(0.95, v));
}
export function predictSectionAgitation(layers) {
let base = stackAgitation(layers);
// Composite penalty: wall-to-wall canvases integrate as one field even when
// each scene individually is 0.15–0.25 after B-windowing. Eye sees swirl, proxy sees calm.
const canvasCount = layers.filter((l) => {
try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }
}).length;
const hasGroundCanvas = layers.some((l) => l.role === 'ground' && (() => { try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }})());
const hasShotCanvas = layers.some((l) => (!l.role || l.role !== 'ground') && (() => { try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }})());
if (hasGroundCanvas && hasShotCanvas) base += 0.28;
else if (canvasCount >= 2) base += 0.18;
// Dense stacks (ground+shot+overlay) read as heavier even if per-scene agitation is low.
if (layers.length >= 3) base += 0.07;
return Math.min(1, base);
}
/**
* Validate one built section's layers.
* @param {object} section - LookGenerator section with `layers` (ground+shot+overlays)
* @param {object} opts - {director, story}
* @returns {{ok:boolean, reason:string|null, desired:number, predicted:number}}
*/
export function validateSection(section, { director, story = null, summary = null } = {}) {
if (!section || !section.layers || !section.layers.length) {
return { ok: true, reason: null, desired: 1, predicted: 0 };
}
const kind = section.kind || 'sustain';
const desired = desiredFor(director, story, kind, summary);
const predicted = predictSectionAgitation(section.layers);
if (predicted <= desired + 1e-9) {
return { ok: true, reason: null, desired, predicted };
}
const primary = section.layers.find((l) => !l.role || l.role !== 'ground') || section.layers[0];
const name = primary && primary.module ? primary.module.name : '?';
const reason = `agitation ${predicted.toFixed(2)} > desired ${desired.toFixed(2)} for ${kind} (shot ${name})`;
return { ok: false, reason, desired, predicted, refused: predicted > desired + 0.22 };
}
/**
* Validate all sections of a look.
* @returns {{sections: object[], ok:boolean, rejected:int[]}}
*/
export function validateLook(look, _track = null) {
const sections = [];
let ok = true;
const rejected = [];
const refused = [];
const directorObj = typeof look.director === 'string'
? { name: look.director }
: look.director || { name: look.director };
const summary = look.summary || (_track && _track.summary) || null;
for (const sec of look.sections) {
const story = sec.story || null;
const v = validateSection(sec, { director: directorObj, story, summary });
sections.push({ index: sec.index, kind: sec.kind, ...v });
if (!v.ok) { ok = false; rejected.push(sec.index); if (v.refused) refused.push(sec.index); }
}
const songRefused = refused.length >= 2;
return { sections, ok, rejected, refused, songRefused, refusesToRender: songRefused };
}
export { KIND_DESIRED_FACTOR };
+24
View File
@@ -64,6 +64,18 @@ const DEFAULT_CROWD = 1.2;
*/
const DEFAULT_BLAZE = 0.15;
/**
* Whole-screen comfort — the floating-window cap that keeps uniform flashing
* and coherent full-frame motion out of the wrong places.
*
* Each director declares how much agitation (engine/agitation.js, 0..1, worst
* 1 s window, max of flash/3 and motion/0.55) it tolerates. Regions moving is
* fine; the whole screen moving or flashing in sync is reserved for special
* cases. Absent -> DEFAULT_COMFORT (0.70). Lower = stricter. See look/comfort.js
* for the story-tension nudge and the weighting the generator reads.
*/
const DEFAULT_COMFORT = 0.70;
/**
* Each director maps every section kind to three families, most-preferred
* first. Every kind must be present — a missing one silently falls back to the
@@ -72,6 +84,7 @@ const DEFAULT_BLAZE = 0.15;
export const DIRECTORS = [
{
name: 'ambient',
comfort: 0.60,
// Space, not glare. A blaze here is the exception that proves it.
blaze: 0.1,
// Space is the subject, so the frame stays owed to it: one
@@ -97,6 +110,7 @@ export const DIRECTORS = [
},
{
name: 'brutalist',
comfort: 0.60,
// Mass does not glow. Almost never.
blaze: 0.05,
// One large thing, seen whole. Layering hides mass, which is the
@@ -120,6 +134,7 @@ export const DIRECTORS = [
},
{
name: 'organicist',
comfort: 0.65,
// Light through leaves — bloom belongs to this world.
blaze: 0.2,
// Growth accumulates. Things overlap here because that is what
@@ -143,6 +158,7 @@ export const DIRECTORS = [
},
{
name: 'corrupt',
comfort: 0.85,
// Overload is the subject. Half its drops go white.
blaze: 0.5,
// Everything over everything. The damage is the subject and it is
@@ -167,6 +183,7 @@ export const DIRECTORS = [
},
{
name: 'geometer',
comfort: 0.70,
// Exact, and occasionally exact and incandescent.
blaze: 0.15,
// Pattern on pattern is a moiré, which is a pattern. Layers are
@@ -232,6 +249,13 @@ export function directorByName(name) {
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
}
/** How much whole-screen agitation this director tolerates (0..1). See DEFAULT_COMFORT. */
export function comfortOf(director) {
return (director && director.comfort !== undefined) ? director.comfort : DEFAULT_COMFORT;
}
export { DEFAULT_COMFORT };
// ── palette progression ───────────────────────────────────────────────
/**
+47
View File
@@ -0,0 +1,47 @@
// Pin one viz as the subject of every section's stacks.
//
// The same override in two places: the venue gallery's viz picker and the
// app's `?viz=`. One code path, so a gallery card and its app deep-link
// render the same pixels — the venue ground (and overlays) stay untouched,
// only the subject slot is re-cast. Seeds derive from (viz, section, look
// seed), so the pin is stable across rebuilds and identical everywhere.
import { Rng, hashString } from '../engine/rng.js';
import { sampleValues } from '../params/schema.js';
import { subjectIndexOf } from './stack.js';
import { scenes } from '../scenes/registry.js';
/**
* @param {object} look LookSpec from generateLook (mutated in place)
* @param {string} name scene name, e.g. 'Synthwave Corridor'
* @returns {object} the pinned scene module
*/
export function pinViz(look, name) {
const viz = scenes.find((s) => s.name === name);
if (!viz) throw new Error(`unknown viz "${name}"`);
const seedBase = look.seed >>> 0;
const temper = look.personality && look.personality.temperament;
for (const sec of look.sections) {
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase}`));
for (let v = 0; v < (sec.variants || []).length; v++) {
const stack = sec.variants[v];
if (!stack) continue;
const at = subjectIndexOf(stack);
const cur = stack[at];
if (!cur) continue;
stack[at] = {
module: viz,
params: sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper),
seed: rng.int(0, 0x7fffffff),
// A model viz composites by alpha; a fragment viz needs the
// key/screen path over the venue ground. Reuse the old blend
// when it already fits, else fall back to the compositor-safe one.
blend: viz.kind === 'model' ? 'normal'
: (cur.blend === 'normal' ? 'lumakey' : cur.blend),
opacity: cur.opacity ?? 1,
blaze: cur.blaze ?? false,
};
}
}
return viz;
}
+81
View File
@@ -0,0 +1,81 @@
// Scene filter — hard exclude before casting, from ranged preliminary metrics.
//
// The director used to lean (agitationWeight) and a 0.70 ceiling; a lean cannot
// exclude — `corrupt 0.85 + tension→0.95` let most canvases through. Now every
// scene carries 12+ ranged metrics `{mean,min,max,std,p10,p90}` in
// `src/scenes/metadata.json:7` (see `src/checks/gallery.js` 6×90-frame ranged
// + `src/checks/metadata.js`). The filter reads `max` (worst draw) against a
// per-kind desired ceiling and returns the eligible subset. Some songs simply
// won't use certain scenes — by design. If the kind's eligible set empties, we
// widen `ceiling*1.15` once before falling back to full `canBackground` so a
// section is never empty; validator still rejects if still hot.
//
// Pure function, no rendering. Tunable via `src/look/comfort.js` ceilings.
import { comfortCeilingFor } from './comfort.js';
import { canBackground } from '../params/schema.js';
import metadataBundled from '../scenes/metadata.json';
// Per-kind comfort multipliers: quiet kinds demand much calmer maxima.
const KIND_COMFORT_FACTOR = {
intro: 0.62,
build: 0.85,
drop: 1.00,
sustain: 0.92,
breakdown: 0.62,
outro: 0.65,
};
function kindFactor(kind) {
return KIND_COMFORT_FACTOR[kind] ?? 0.85;
}
function maxForMetric(scene, key) {
const row = metadataBundled.scenes && metadataBundled.scenes[scene.name];
if (!row) return null;
const r = row.metrics && row.metrics[key];
if (r && typeof r.max === 'number') return r.max;
const flat = { agitation: row.agitation, flashPeak: row.flashPeak, motionPeak: row.motionPeak, structPeak: row.structPeak, coverage: row.coverage };
const v = flat[key];
return typeof v === 'number' ? v : null;
}
/**
* Whether a scene passes the per-kind ranged gate.
* Checks agitation.max (composite), plus motion/struct peaks — a ring field can
* hide behind a middling composite while structure max alone is hot.
*/
function passesKind(scene, ceiling, kind) {
const aMax = maxForMetric(scene, 'agitation');
const mMax = maxForMetric(scene, 'motionPeak');
const sMax = maxForMetric(scene, 'structPeak');
const cMax = maxForMetric(scene, 'coverage');
const want = ceiling * kindFactor(kind);
if (aMax !== null && aMax > want) return false;
if (sMax !== null && sMax > 0.16) return false;
if (kind === 'intro' || kind === 'breakdown' || kind === 'outro') {
if (mMax !== null && mMax > 0.38) return false;
if (cMax !== null && cMax > 0.88) return false;
}
return true;
}
/**
* Filter scenes before casting — hard exclude, not a weight.
* Synchronous, uses bundled metadata. Never returns empty.
*/
export function filterScenesSync(scenes, { director, story = null, sectionKind = 'sustain' } = {}) {
if (!scenes || !scenes.length) return [];
const baseCeiling = comfortCeilingFor(director, story);
for (const scale of [1, 1.15]) {
const ceiling = Math.min(0.95, baseCeiling * scale);
const eligible = scenes.filter((s) => passesKind(s, ceiling, sectionKind));
if (eligible.length) return eligible;
}
const fb = scenes.filter((s) => { try { return canBackground(s); } catch { return true; } });
return fb.length ? fb : scenes.slice(0, Math.max(1, scenes.length));
}
// Keep alias for callers that expect filterScenes
export const filterScenes = filterScenesSync;
export { KIND_COMFORT_FACTOR };
+543
View File
@@ -0,0 +1,543 @@
// Where the ROOM camera is, and what it is looking at — per shot, planned once.
//
// The viz has framing (wide/normal/close) and gaze (a continuous path); the
// room camera used to have one slow traverse and stare forward while the body
// glided. So the room gets its own shot plan, with the same two devices as
// the viz: SIZE steps at cuts (a close answered by a wide reads as two shots
// of one place), and a look target with a MOTIVE — the screen when the viz is
// the action, the whole room when the song breathes.
//
// The plan is data, evaluated per frame as a pure function like the gaze. The
// BODY glides on the continuous traverse (the thing that arcs); the HEAD
// follows a slow progress-driven path through the marks' anchors, leaning
// past halfway at whatever the current section is about. A story jump cuts
// instantly to the new arrangement's side instead of easing, because the
// furniture swapped and an eased move would glide through the old layout.
//
// Bounds are per archetype and conservative: the camera must never leave the
// room or enter the furniture. See test/venue.test.js for the box.
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const lerp = (a, b, t) => a + (b - a) * t;
/**
* Room geometry per archetype: where the screen is, where the furniture
* lives, and the box the camera may occupy. Positions are world units in the
* venue module's scene (chamber room is 22 wide, stage field 44).
*/
export const VENUE_STAGING = {
chamber: {
screen: [0, 1.35, -10.9],
fieldL: [-3.2, -0.4, -4.5],
fieldR: [3.2, -0.4, -4.5],
// Room anchor sits OFF the screen axis: a room visit must pan the
// screen aside, or the composition never changes no matter how far the
// body glides (measured: screen-centred room views 0.08 apart at 120 s
// gaps). The side alternates per visit (see placeMark).
roomMid: [0, 0.3, -5],
roomSide: 2.8,
box: { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 },
// Dolly per shot size: close sits among the furniture, wide sees the room.
dolly: { wide: 6.2, normal: 4.0, close: 2.0 },
},
stage: {
screen: [0, 2.2, -14.5],
fieldL: [-4.5, 0.0, -6.0],
fieldR: [4.5, 0.0, -6.0],
roomMid: [0, 0.4, -7],
roomSide: 4.2,
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
},
// The ride: the "screen" is the small dash radio at (0, 0.82, -0.8),
// facing +z (rearward) at the passengers; the car sits at the origin
// driving toward -z. Screen visits shoot from the rear hemisphere (the
// only side the radio faces); the camera works the car like a chase unit
// otherwise — orbits, pull-backs for the road, and passerby plants it on
// the shoulder while the car sweeps through — inside a box that keeps it
// out of the bodywork.
ride: {
screen: [0, 0.82, -0.8],
fieldL: [-3.4, 0.2, -3.0],
fieldR: [3.4, 0.2, -3.0],
roomMid: [0, 0.3, -6],
roomSide: 3.6,
// Tighter than stage: the car is small, so the camera works closer
// or the vehicle drowns in black road (measured 6% painted wide).
box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 },
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
},
// The dungeon: the "screen" is the way ahead (the party's lanterns, the
// portal glow down the corridor); the "room" is the stone around the
// walk. Marks mostly steer the facade's own walk/room/portal modes —
// the pos/look bends the look path, the target picks the mode.
dungeon: {
screen: [0, 1.2, -8],
fieldL: [-2.4, 0.2, -4.0],
fieldR: [2.4, 0.2, -4.0],
roomMid: [0, 0.6, -5],
roomSide: 2.6,
box: { x: 4.0, yLo: -0.2, yHi: 2.4, zLo: -6.0, zHi: 6.0 },
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
},
};
/** Shot sizes for the room — same three words as the viz, different numbers. */
export const VENUE_SHOT_SIZES = ['wide', 'normal', 'close'];
/**
* Plan one camera mark per cue — but size/target move at SECTION granularity.
*
* Two densities, one plan. The look path needs DENSE anchors (every cue): it
* interpolates through them continuously, and five section anchors over three
* minutes is a static stare. Size and target move per SECTION: the room holds
* its shot across phrases (near probes share the framing) and steps at story
* beats. Per-cue sizes made the room as twitchy as the viz — twenty marks a
* song, every probe on a different scale, all pairs equally far (shuffle).
* Intra-section movement is continuous travel; inter-section movement steps.
* That split is the arc.
*
* @param {Array} cues ArcDriver cues (need index, sectionIndex, atSectionStart, fadeFrames)
* @param {Array} sections look.sections (need story, bias)
* @param {string} archetype 'chamber' | 'stage' | 'ride'
* @param {object} style look.framing style ({mode, range, changeChance}); 'locked' parks the room
* @param {Function} isJump (cue) => jump|null — arrangement jumps (see ArcDriver.venueJumpAt)
* @param {Rng} rng
* @param {Array} [gazeFrom] viz gaze `from` point per cue — the room takes a
* fraction as lateral offset, so both cameras cut the same way
* @returns {Array} one mark per cue ({size, target, pos, look, jump}); size and
* target are constant within a section, pos/look vary per cue
*/
export function planVenueShots(cues, sections, archetype, style, isJump, rng, gazeFrom = null) {
const staging = VENUE_STAGING[archetype] || VENUE_STAGING.chamber;
// Locked pins SIZE (like the viz: a locked track never changes size) but
// never attention — a video that never changes size can still be one whose
// attention moves (see look/Camera.js). Parking the look target too is
// what froze the room on locked tracks.
const locked = !style || style.mode === 'locked';
// The video globally pushes IN: lateral marches failed because mirrored
// views of a symmetric room look alike (near probes differed by target,
// far probes converged — negative direction). A dolly march is
// irreversible — far-apart probes are different scales, guaranteed — while
// per-cue sizes still step around the marching centre so cuts read.
const n = Math.max(1, cues.length);
const marks = [];
let previous = null;
// The room's off-axis home for this video, drawn once: room visits face
// one wall all song (see placeMark). Drawn here — once, before the loop —
// so rng consumo order is seek-stable.
const roomSide = rng.bool(0.5) ? 1 : -1;
// Size/target are decided once per section and carried across its cues;
// pos/look are drawn per cue so the look path stays dense. The streak
// breaker reads sectionTargets (one entry per section): a cue-granularity
// history would break every streak on its third cue, since all cues in a
// section share the target.
let sectionSize = null, sectionTarget = null, sectionIndex = -1;
// Planted shots (passerby/billboard) hold the camera still while the car
// does the travelling — one cue of that is a beat, a whole section of it
// is a stuck camera with the car looping through the same frame. The
// plant opens its section; the section's remaining cues fall back to the
// screen/room rotation. (The section still counts as planted in
// sectionTargets, so the adjacency rules stay conservative.)
let sectionPlants = 0;
const sectionTargets = [];
for (const cue of cues) {
const section = sections[cue.sectionIndex] || {};
const story = section.story || {};
const energy = (section.bias && section.bias.energy) ?? 0.5;
const jump = isJump ? isJump(cue) : null;
// A sharp transition re-picks the shot even mid-section: the room
// camera must cut to a different angle/position/direction with the
// image, never hold static across a hard cut. Motive yields to
// grammar here — a cutaway needn't face the action.
const sharpCut = !!cue.hardCut && marks.length > 0;
if (cue.sectionIndex !== sectionIndex || sharpCut) {
sectionIndex = cue.sectionIndex;
sectionPlants = 0;
const prevMark = marks.length ? marks[marks.length - 1] : null;
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
if (sharpCut && prevMark && sectionSize === prevMark.size) {
sectionSize = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== sectionSize));
}
if (sharpCut && prevMark) {
// Flip the look direction outright: screen↔room (walk↔room
// in the dungeon), so the new composition cannot be the old
// one from a step aside.
// (Passerby/billboard/portal have no opposite — re-draw instead.)
const pt = prevMark.target;
sectionTarget = pt === 'screen' ? 'room'
: pt === 'room' ? 'screen'
: pt === 'walk' ? 'room'
: pt === 'room' ? 'walk'
: archetype === 'dungeon' ? (rng.bool(0.5) ? 'walk' : 'room')
: (rng.bool(0.5) ? 'screen' : 'room');
} else {
sectionTarget = pickTarget(prevMark, energy, story, rng,
sectionTargets.map((t) => ({ target: t })), archetype);
}
// A billboard shot wants car + board in frame: force wide.
// A portal tableau wants the ring + procession in frame: wide too.
if ((sectionTarget === 'billboard' || sectionTarget === 'portal') && !locked) sectionSize = 'wide';
sectionTargets.push(sectionTarget);
}
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0];
// Only the section's first planted cue keeps the plant; later ones
// rejoin the rotation, alternating away from whatever just played so
// the cut out of the plant always changes the composition.
let cueTarget = sectionTarget;
if (cueTarget === 'passerby' || cueTarget === 'billboard' || cueTarget === 'portal') {
sectionPlants++;
if (sectionPlants > 1) {
const pt = marks.length ? marks[marks.length - 1].target : null;
cueTarget = archetype === 'dungeon'
? (pt === 'walk' ? 'room' : 'walk')
: (pt === 'screen' ? 'room' : 'screen');
}
}
const mark = placeMark(staging, sectionSize, cueTarget, jump, rng, progress, gaze, roomSide);
marks.push({
index: cue.index,
size: sectionSize, target: cueTarget,
pos: mark.pos,
look: mark.look,
jump: jump ? jump.arrangement : null,
hardCut: !!cue.hardCut,
// The move eases over the dissolve, except on a jump cut.
easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12),
});
}
return marks;
}
/** Shot size steps at cuts: never repeat, loud leans close, quiet leans wide. */
function pickSize(previous, energy, story, style, rng) {
const closeness = story.closeness ?? 0.5;
// A locked-off track still gets its one size from the same draw.
if (previous && style && !rng.bool(style.changeChance ?? 0.6)) return previous.size;
const act = story.act || 'development';
// Builds tighten toward the hit; the climax lands close; resolutions and
// quiet opens go wide. Multipliers on the energy lean, not overrides — a
// quiet climax can still play wide.
let wideM = 1, closeM = 1;
if (act === 'setup' || act === 'resolution') { wideM = 1.8; closeM = 0.5; }
else if (act === 'climax') { closeM = 1.8; wideM = 0.5; }
const weights = [
(1 + (1 - energy) * 2.5) * (1.4 - closeness * 0.9) * wideM, // wide
2, // normal
(1 + energy * 2.5) * (0.6 + closeness * 0.9) * closeM, // close
];
let size = rng.pickWeighted(VENUE_SHOT_SIZES, weights);
if (previous && size === previous.size) {
size = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== size));
}
return size;
}
/**
* WHAT the camera looks at — the motive.
*
* screen the viz is the action: face the screen.
* room the song breathes: the whole place.
*
* Deliberately two targets, not three. Furniture visits (fieldL/fieldR) were
* tried and measured as outliers — 0.2+ from every other probe, flattening or
* inverting direction wherever they landed. The screen is the stage, the room
* is the house; the camera does not cut to the audience mid-song. Furniture
* still crosses the frame — the walk passes it — it is just never the mark.
* Rotation between the two (never three in a row) is the coverage.
*/
function pickTarget(previous, energy, story, rng, history, archetype = null) {
const last2 = (history || []).slice(-2).map((m) => m.target);
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
const act = story.act || 'development';
const tension = story.tension ?? 0.5;
// Ride-only: a passerby section plants the camera at the roadside and lets
// the car drive through the frame. Offered on breaths (setup/resolution,
// quiet passages) — never twice running, never right after a billboard
// section, never at the climax: two planted shots back to back read as
// stuck, in either order (the billboard rule below guards the mirror).
// The draw is consumed even when adjacency blocks the plant so the rng
// stream — and every plan without a blocked draw — stays exactly as
// before. (A streak check cannot do this: sectionTargets holds one entry
// per section, so single-cue plant sections never form a streak of two.)
const passerbyDraw = archetype === 'ride' && act !== 'climax'
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
&& rng.bool(0.45);
if (passerbyDraw && last2[last2.length - 1] !== 'passerby'
&& last2[last2.length - 1] !== 'billboard') {
return 'passerby';
}
// Ride-only: a billboard section aims a static camera at the roadside
// billboard while the car drives through the frame in front of it. Rarer
// than passerby (at most a couple per video), same breath-only rule, never
// adjacent to a passerby — two planted shots back to back read as stuck.
if (archetype === 'ride' && act !== 'climax' && !streak
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
&& last2[last2.length - 1] !== 'passerby' && rng.bool(0.3)) {
return 'billboard';
}
// Dungeon-only: a portal section plants the camera on the party's path
// and watches it process through the seated portal. Rarest plant (at most
// a couple per video), same breath-only rule, never adjacent to itself —
// two planted tableaux back to back read as stuck. Consumes its draw even
// when blocked, like passerby.
const portalDraw = archetype === 'dungeon' && act !== 'climax'
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
&& rng.bool(0.3);
if (portalDraw && last2[last2.length - 1] !== 'portal') {
return 'portal';
}
// Dungeon travel: walk is the neutral tread, room is the watch lean —
// first-person against planted. Same act/energy grammar as screen/room.
if (archetype === 'dungeon') {
let dpick;
if (act === 'setup') dpick = 'walk';
else if (act === 'resolution') dpick = rng.bool(0.3) ? 'room' : 'walk';
else if (act === 'climax') dpick = 'room';
else if (act === 'turn') dpick = rng.bool(0.75) ? 'room' : 'walk';
else if (energy > 0.66 || tension > 0.7) dpick = 'room';
else if (energy < 0.36) dpick = 'walk';
else dpick = rng.bool(0.5) ? 'room' : 'walk';
if (streak && dpick === streak) dpick = streak === 'room' ? 'walk' : 'room';
return dpick;
}
let pick;
if (act === 'setup') pick = 'room';
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
else if (act === 'climax') pick = 'screen';
else if (act === 'turn') pick = rng.bool(0.75) ? 'screen' : 'room';
else if (energy > 0.66 || tension > 0.7) pick = 'screen';
else if (energy < 0.36) pick = 'room';
else pick = rng.bool(0.5) ? 'screen' : 'room';
if (streak && pick === streak) pick = streak === 'screen' ? 'room' : 'screen';
void previous;
return pick;
}
/** Absolute camera mark for a (size, target): position + look point. */
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
// Passerby (ride only): plant the camera on the shoulder and look back up
// the road. The car enters far, drives to the camera and past it — the
// module drives the car through on driveT, so pos/look here are static.
// Shoulder side follows the video's roomSide: one consistent side all song.
if (target === 'passerby') {
const side = roomSide || 1;
// Planted a step back and up from the lane so the drive-by whoosh
// does not swallow the frame, aimed between the approach (far +z)
// and the pass point: the car grows head-on in the first third,
// crosses near centre mid-cue, and the tail holds road + glow.
return {
pos: [side * 4.4, 1.5, -2],
look: [-side * 0.5, 0.3, 3],
};
}
if (target === 'billboard') {
// Anchor where billboards live (far shoulder, panel height): the
// module aims at the live panel each frame, so this only bends the
// look-path sweep toward a sensible place for neighboring cues.
// Forced wide in the planner (see below) — a billboard shot wants
// the car AND the board in frame.
const side = roomSide || 1;
return {
pos: [side * 3.6, 1.4, 0],
look: [-side * 9.0, 3.0, -20],
};
}
if (target === 'portal') {
// Planted tableau: the camera stands off the path while the party
// processes through the seated portal. The facade seats the portal
// ring AT the look point each frame, so pos/look here only need to
// agree with each other — a usable two-shot of ring + approach.
// Side follows the video's roomSide: one consistent side all song.
const side = roomSide || 1;
return {
pos: [side * 2.6, 1.5, 1.0],
look: [-side * 0.5, 1.3, -8],
};
}
// Dungeon travel modes share the room/screen anchors: walk reads the
// way ahead (screen), room reads the place around the walk (room).
const motive = target === 'walk' ? 'screen' : target === 'room' ? 'room' : target;
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const { box, dolly } = staging;
// Room visits alternate sides so consecutive room views face opposite
// walls (the screen slides left, then right) instead of both staring down
// the middle. Screen visits stay centred — the screen IS the axis.
let anchor = motive === 'screen' ? staging.screen : staging.roomMid;
if (motive === 'room') {
// ONE side per video (threaded in as roomSide): alternating walls is a
// zigzag (left-right-left reads as churn, measured flat), while one
// steady side gives the look path a consistent off-axis home it keeps
// returning to. Across videos the side varies by seed.
const side = roomSide || 1;
anchor = [side * (staging.roomSide || 2.5), staging.roomMid[1], staging.roomMid[2]];
}
// Dolly centre marches wide→close across the video (eased: linger wide
// early, arrive late); the shot size steps around it so cuts still read
// while far-apart probes are irreversibly different scales. Lateral
// position alternates sides per visit (seeded) — cross the room, don't
// orbit one side. A jump takes its arrangement side.
const pe = progress * progress * (3 - 2 * progress);
const centreZ = dolly.wide + (dolly.close - dolly.wide) * (0.25 + 0.75 * pe);
const sizeOff = { wide: 0.9, normal: 0, close: -0.9 }[size] ?? 0;
const z = centreZ + sizeOff + rng.range(-0.4, 0.4);
let x;
if (jump) {
const h = (jump.arrangement * 0x9e3779b9) >>> 0;
x = (h % 2 === 0 ? 1 : -1) * box.x * 0.55;
} else {
const side = rng.bool(0.5) ? 1 : -1;
x = side * box.x * rng.range(0.25, 0.65);
}
// Close on the screen stands nearly centre (it fills the frame anyway);
// wides take the sides (parallax over the furniture is the point).
const cx = size === 'close' && motive === 'screen' ? x * 0.3 : x;
// A fraction of the viz gaze relocation: both cameras cut the same way.
// Kept small (a third) — full coupling threw the room around per section.
const gx = gaze ? gaze[0] * 0.35 : 0;
const gy = gaze ? gaze[1] * 0.2 : 0;
const pos = [
clampTo(cx + gx, -box.x, box.x),
rng.range(box.yLo, box.yHi) + gy,
clampTo(z, box.zLo, box.zHi),
];
// Look commits to the motive anchor — per target, not one lean for both.
// Screen visits lean 0.55 (the screen shifts in frame but stays the
// subject); room visits lean 0.9, nearly full (the screen slides out and
// the frame becomes wall + furniture — a genuinely different composition).
// A single lean for both kept every probe as "screen + room", which
// brightness-normalization reduces to structural twins (measured: 28% vs
// 68% painted scoring 0.10 apart). NO rng offsets on the anchors:
// per-mark jitter is a random walk the descriptor reads as churn.
// Consecutive anchors must differ ONLY by motive, never by noise.
const screenAnchor = staging.screen;
const forward = [0, 0.3, screenAnchor[2]];
const k = motive === 'room' ? 0.9 : 0.55;
const look = [
lerp(forward[0], anchor[0], k),
lerp(forward[1], anchor[1], k),
lerp(forward[2], anchor[2], k),
];
return { pos, look };
}
/**
* Evaluate the plan at a frame: travel from the previous mark to this cue's
* across most of the cue, not just the dissolve.
*
* Easing only over the dissolve leaves the camera HOLDING a well-composed shot
* for the rest of every cue — and held compositions all look alike to a
* structural descriptor (measured: six held screen views 0.06-0.13 apart, flat
* direction). Travelling instead — always on the way to the next mark — keeps
* the composition shifting progressively, which is what direction measures.
* Arrives at 80% of the cue so cuts land on a readable frame, not mid-glide.
* Jump cues cut instantly (the furniture swapped; gliding through the old
* layout is the glitch). Pure in (marks, frame): seeks agree with playback.
*/
/** Dolly factor per room shot size: a close stands nearer, a wide further. */
export const VENUE_SIZE_DOLLY = { wide: 1.3, normal: 1.0, close: 0.72 };
export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = null, progress = null) {
// Marks are per-cue (size/target constant within a section, pos/look dense).
const cur = marks[cueIndex] || marks[marks.length - 1];
if (!cur) return null;
const prev = cueIndex > 0 ? marks[cueIndex - 1] : null;
// The LOOK travels a continuous path through every mark anchor in order,
// driven by video progress — never by cue. Per-cue look jumps (screen→
// furniture→room) read as churn to the descriptor even when they read as
// coverage to an eye: adjacent probes land on different anchors (big gaps)
// while far probes converge (mirrored compositions). A slow path through
// the same anchors visits everything AND accumulates. Only story jumps cut
// the look (the furniture swapped; the path would glide through the old
// room). The reported target stays the cue's (HUD motive), the look itself
// is where the path is.
// Passerby/billboard/portal hold their plants: the camera is a bystander, and
// any drift of the look would read as a pan away from the road. driveT
// (0→1 across the cue) is the car's cue — the module drives it from far
// +z to far -z on it (passerby) or aims at the live panel (billboard).
const plantedMark = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal';
const look = plantedMark
? cur.look.slice()
: lookPathAt(marks, progress, cur.jump !== null ? cur : null);
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
const driveT = clamp01((frame - cueStartFrame) / span);
// A sharp transition lands instantly on the new mark — position, size and
// look together. The image swaps on this frame, so the reframe lands
// exactly when the eye is least able to see it move. (Same rationale as
// the jump branch: easing through a cut reads as a glitch, not an edit.)
if (!prev || cur.jump !== null || cur.hardCut) {
return {
pos: cur.pos.slice(), look,
size: cur.size, target: cur.target,
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
drive: driveT,
};
}
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
// the next mark and never holds a composition — the gaze `drift` grammar.
// Held compositions all looked alike (flat direction); eased-then-held
// moves made adjacent probes as far apart as distant ones (pan cuts read
// as churn to the descriptor). Travel-portion differences are small up
// close and accumulate far away, which is exactly the gradient direction
// measures. Jump cues still cut instantly (furniture swapped).
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
const e = t * t * (3 - 2 * t);
// A planted camera holds its position too — the bystander does not drift
// down the road while the car passes. (The look already holds above; the
// module adds handheld micro on top.)
const planted = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal';
return {
pos: planted ? cur.pos.slice()
: [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)],
look,
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
target: cur.target,
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
drive: driveT,
};
}
/**
* Continuous look path: ONE steady sweep across the video, plus local motive.
*
* Interpolating anchors in plan order zigzags (screen-room-screen reads as
* churn: binary targets scattered in time can never correlate with time gap,
* so direction flats no matter the amplitude). Instead the look sweeps
* monotonically — left to right (seeded), rising slightly — while each mark's
* anchor pulls a quarter toward the motive. Far probes always differ (the
* sweep guarantees it); near probes share direction; the motive textures the
* sweep without breaking it. Pure in (marks, progress).
*/
function lookPathAt(marks, progress, jumpMark) {
if (jumpMark) return jumpMark.look.slice();
if (!marks.length) return [0, 0.3, -5];
const p = clamp01(progress ?? 0.5);
// Sweep direction from the plan content (deterministic, varies per video):
// hash the first mark's position so two videos sweep different ways.
const h = marks.length ? (Math.abs(marks[0].pos[0] * 997 + marks[0].pos[1] * 613) | 0) : 0;
const dir = (h % 2 === 0) ? 1 : -1;
const sweepX = (p - 0.5) * 2 * 1.6 * dir;
const sweepY = 0.15 + p * 0.45;
// Nearest mark's anchor for the motive quarter.
const q = p * (marks.length - 1);
const i = Math.min(marks.length - 1, Math.round(q));
const a = marks[i].look;
// The sweep is the look; the anchor bends it a quarter toward the motive.
// Depth stays on the screen plane so the screen never leaves focus depth.
return [
sweepX * 0.75 + a[0] * 0.25,
sweepY * 0.75 + a[1] * 0.25,
a[2],
];
}
/** One line for the HUD. */
export function describeVenueShot(mark) {
if (!mark) return 'room: —';
return `room: ${mark.size} on ${mark.target}`;
}
+478 -8
View File
@@ -9,6 +9,14 @@ import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js';
import { getLlmConfig, setLlmConfig } from './llm/config.js';
import { canInvoke, remainingText, getBudgetCounts } from './llm/budget.js';
import { requestSongTweak } from './llm/llmTweak.js';
import { synthesizeSong } from './audio/synth.js';
import { FeatureTrack } from './audio/FeatureTrack.js';
import { generateLook } from './look/LookGenerator.js';
import { SONGS } from './audio/songbank.js';
import { captureThumbnails } from './llm/thumbs.js';
const QUALITY = {
draft: 0.5, // half resolution — for scrubbing heavy stacks
@@ -44,8 +52,37 @@ const dom = {
exportWarnDontAsk: document.getElementById('ew-dontask'),
};
// Show owns the single WebGL2 context. three@0.181 throws when the browser
// blocks context creation (WebGL2 disabled, headless shell without
// SwiftShader) — historically a blank page, since this runs at module scope.
// Fail loudly into the boot-error box instead, with the cause and the fix.
function showBootError(detail) {
const box = document.getElementById('boot-error');
const pre = document.getElementById('be-detail');
if (pre) pre.textContent = detail;
if (box) box.hidden = false;
}
function bootShow() {
try {
return new Show({ canvas: dom.canvas, width: 1280, height: 720 });
} catch (err) {
const msg = err && err.message ? err.message : String(err);
showBootError(
'The WebGL renderer could not start, so nothing on this page can run — ' +
'every preview, gallery and check renders through the same context.\n\n' +
msg,
);
// Tell the boot guard the startup story is over (it would otherwise
// report its own generic text, clobbering the specific cause above),
// then stop: a half-running app with no renderer is worse than none.
window.__FLOW_STATE_READY__ = 'failed';
throw err;
}
}
const state = {
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
show: bootShow(),
playing: false,
quality: 'full',
loopSection: -1,
@@ -63,11 +100,24 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
// ---------------------------------------------------------------- loading
async function loadFile(file, { seed = null } = {}) {
async function loadFile(file, { seed = null, pins = null, frame = null, warm = null } = {}) {
if (state.busy) return;
state.busy = true;
stopPlayback();
// A dropped file carries no pins of its own — inherit the view settings
// from the URL (a demo deep-link's venueSel/viz), so the room and subject
// you came to see survive the new track. The bank path always passes an
// explicit pins object and is unaffected. Frame/warm never carry over:
// they address the old song's timeline.
const fromDrop = pins == null;
if (fromDrop) {
try {
const q = new URLSearchParams(location.search);
if (q.get('venueSel') || q.get('viz')) {
pins = { venueSel: q.get('venueSel'), viz: q.get('viz') };
}
} catch { /* non-browser — no pins */ }
}
if (dom.overlay) {
dom.overlay.hidden = true;
dom.overlay.style.display = 'none';
@@ -83,7 +133,7 @@ async function loadFile(file, { seed = null } = {}) {
await state.show.load(file, (stage, fraction) => {
dom.thStep.textContent = stage;
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
}, { seed });
}, { seed, pins });
dom.audio.src = URL.createObjectURL(file);
dom.thLabel.textContent = 'loaded track';
@@ -96,13 +146,31 @@ async function loadFile(file, { seed = null } = {}) {
strip.setShow(state.show);
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
resize();
seekTo(0);
// Deep link from a preview (filmstrip figure, venue-gallery card):
// pins ride into load() so the arc builds pinned layers once, then
// the exact frame with the probe's warm-up so stateful layers land
// converged like the probe render. Dropped files carry inherited
// pins (above) but always start at frame 0.
const deepLink = !!(pins && (pins.venueSel || pins.viz)) || Number.isFinite(frame);
// Prime before the landing seek on deep-link loads: programs link
// asynchronously and a draw against an unlinked program is wrong
// output — the probe pages prime for the same reason, and both sides
// must be linked for the frames to agree. Normal loads skip it.
if (deepLink) state.show.prime();
seekTo(Number.isFinite(frame) ? frame : 0,
Number.isFinite(warm) ? { warmup: warm } : {});
if (fromDrop) clearBankParamsFromUrl();
renderPanel();
document.title = `flow-state · ${state.show.fileName}`;
console.info('[flow-state]', describeLook(state.show.look));
} catch (err) {
dom.thLabel.textContent = 'analysis failed';
dom.thStep.textContent = `failed: ${err.message}`;
const refused = err && err.code === 'COMPOSITION_REFUSED';
dom.thLabel.textContent = refused ? 'high agitation — refuses to render' : 'analysis failed';
const detail = refused
? `director refused song: ${err.message} — try another seed, or a calmer song (murk is dark-noisy bright-hiss; validator expected static bed + small floaters)`
: `failed: ${err.message}`;
dom.thStep.textContent = detail;
if (refused) dom.thStep.title = detail;
console.error(err);
dom.changeTrack.hidden = false;
dom.changeTrack.textContent = 'try again';
@@ -120,6 +188,27 @@ if (dom.changeTrack) dom.changeTrack.addEventListener('click', () => dom.fileInp
dom.fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) loadFile(e.target.files[0]);
});
/**
* A dropped file has no URL identity, so the bank addressing it by name
* (?song=&seed=&frame=&warm=) must go once the file loads — otherwise the
* URL keeps describing the previous song while another plays (and rerolls
* would persist a seed against the wrong name). View settings
* (?venue=&venueSel=&viz=) stay: they describe how you watch, not what.
* Uses replaceState — no reload, no history spam.
*/
function clearBankParamsFromUrl() {
try {
const q = new URLSearchParams(location.search);
if (!q.get('song') && !q.get('seed') && !q.get('frame') && !q.get('warm')) return;
q.delete('song');
q.delete('seed');
q.delete('frame');
q.delete('warm');
const rest = q.toString();
history.replaceState(null, '', rest ? `${location.pathname}?${rest}` : location.pathname);
} catch { /* non-browser or sandboxed iframe */ }
}
/**
* `?song=centre` — open a bank song straight from a debug page.
*
@@ -127,6 +216,12 @@ dom.fileInput.addEventListener('change', (e) => {
* reading is to watch the thing move, and until now that meant remembering
* which of seventeen near-identically-named wavs to drag in. `&seed=` carries
* the strip's seed across so the video you watch is the one it measured.
* `&frame=` seeks to the exact frame after load (with warm-up), and
* `&venueSel=` re-applies the pinned venue shell — together they make a
* preview deep-link reproduce its image. `&viz=` pins the subject the same
* way, and `&warm=` replays the probe's own warm-up length so feedback
* lands identically converged; deep-link loads prime first so neither side
* renders against unlinked programs. Built by src/ui/preview.js.
*
* Dev-only in practice: `test/songs/` is served by vite from the project root
* and is not part of a build. The name is matched against a bare word rather
@@ -139,7 +234,17 @@ async function loadBankSong(name, seed) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const blob = await res.blob();
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), { seed });
// Preview deep-links (?frame=, ?venueSel=) ride along: the app lands
// on the exact image the preview showed. See src/ui/preview.js.
const q = new URLSearchParams(location.search);
const frame = q.get('frame');
const warm = q.get('warm');
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), {
seed,
pins: { venueSel: q.get('venueSel'), viz: q.get('viz') },
frame: frame === null ? null : Number(frame),
warm: warm === null ? null : Number(warm),
});
} catch (err) {
dom.thLabel.textContent = 'no such song';
dom.thName.hidden = false;
@@ -249,9 +354,26 @@ document.addEventListener('keydown', (e) => {
// ---------------------------------------------------------------- look edits
/**
* Persist the current look seed into the URL so a reload restores it.
*
* Only meaningful with `?song=` (bank songs load by name; a dropped file has
* no URL identity, so a seed alone restores nothing). Uses replaceState — no
* reload, no history spam.
*/
function persistSeedInUrl() {
try {
const q = new URLSearchParams(location.search);
if (!q.get('song')) return;
q.set('seed', String(state.show.look.seed >>> 0));
history.replaceState(null, '', `${location.pathname}?${q}`);
} catch { /* non-browser or sandboxed iframe — seeding still works in-memory */ }
}
document.getElementById('btn-reroll').addEventListener('click', () => {
if (!state.show.ready) return;
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
persistSeedInUrl();
refreshAfterLookChange();
});
@@ -363,6 +485,7 @@ function renderPanel() {
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
<div class="kv"><span>venue</span><b>${show.look.venue ? `${show.look.venue.name} (${show.look.venue.archetype})${show.look.venueEnabled ? '' : ' · off'}` : 'off'}${show.arc && show.arc.state && show.arc.state.venueState ? ` · light ${show.arc.state.venueState.light.toFixed(2)} fog ${show.arc.state.venueState.fog.toFixed(2)} pop ${show.arc.state.venueState.popMul.toFixed(2)}` : ''}</b> <a href="#" id="link-venue-toggle" title="Toggle venue (ground) — ?venue=1 enables for this page">${show.look.venueEnabled ? 'disable' : 'enable'}</a> · <a href="/venue-gallery.html" target="_blank">gallery</a></div>
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
@@ -400,6 +523,7 @@ function renderPanel() {
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
document.getElementById('link-venue-toggle')?.addEventListener('click', (e)=>{ e.preventDefault(); show.setVenueEnabled(!show.look.venueEnabled); renderPanel(); show.seek(show.timeline.frame, {warmup:false}); });
return;
}
@@ -831,3 +955,349 @@ if (query.get('song')) {
const seed = query.get('seed');
loadBankSong(query.get('song'), seed === null ? null : (Number(seed) >>> 0));
}
// ---------------------------------------------------------------- LLM song tweak
let llmTweakInFlight = false;
let currentSongSpec = null; // last synthesizeSong spec (for bank songs); null for dropped files
function specForTrack(track) {
// For bank songs we have the original spec; for dropped files we derive
// a plausible spec from the measured summary so the LLM has something to edit.
if (currentSongSpec) return { ...currentSongSpec };
if (!track || !track.summary) return null;
const s = track.summary;
return {
bpm: Math.round(s.bpm || 120),
brightness: Math.max(0, Math.min(1, s.meanCentroid ?? 0.5)),
noise: Math.max(0, Math.min(1, s.meanFlatness ?? 0.15)),
noiseColour: null,
dynamics: Math.max(0, Math.min(1, s.dynamicRange ?? 0.6)),
transition: 0.5,
arrangement: 'classic',
duration: Math.round(track.duration || 120),
seed: track.summary ? 1 : 1,
};
}
function setLlmTweakButtonState() {
const btn = document.getElementById('btn-llm-tweak');
const gear = document.getElementById('btn-llm-config');
if (!btn) return;
const gate = canInvoke();
const cfg = getLlmConfig();
const ready = state.show && state.show.ready;
btn.disabled = llmTweakInFlight || !ready || !gate.ok;
if (!ready) btn.title = 'Load a track first';
else if (!cfg.isConfigured) btn.title = 'Set an AI Gateway API key — click ⚙️';
else if (!gate.ok) btn.title = gate.detail;
else btn.title = `Let an LLM tune the generated song · ${remainingText()} · click to review`;
if (gear) {
const dot = cfg.isConfigured ? '●' : '○';
gear.textContent = `${dot} ⚙️`;
gear.title = cfg.isConfigured
? `LLM: ${cfg.model} @ ${cfg.gatewayUrl} · ${remainingText()} — click to configure`
: 'LLM not configured — click to set gateway URL / model / API key';
gear.classList.toggle('llm-not-configured', !cfg.isConfigured);
}
}
function openLlmConfigDialog() {
const cfg = getLlmConfig();
const counts = getBudgetCounts();
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box" role="dialog" aria-modal="true" aria-labelledby="llm-cfg-title">
<div class="llm-title" id="llm-cfg-title">LLM gateway settings</div>
<div class="hint" style="margin-bottom:10px;">Stored in <code>localStorage</code> (<code>flow-state:llm</code>). Never committed. Leave API key empty to disable.</div>
<label class="llm-field"><span>Gateway URL</span><input id="llm-gw" type="text" placeholder="${cfg.defaults.gatewayUrl}" value="${escapeHtml(cfg.stored.gatewayUrl || '')}"></label>
<label class="llm-field"><span>Model</span><input id="llm-model" type="text" placeholder="${cfg.defaults.model}" value="${escapeHtml(cfg.stored.model || '')}"></label>
<label class="llm-field"><span>API key</span><input id="llm-key" type="password" autocomplete="off" placeholder="(empty to disable — stored locally)" value="${escapeHtml(cfg.stored.apiKey || '')}"></label>
<div class="hint">Gateway URL / model may also come from <code>.env.local</code> (<code>VITE_AI_GATEWAY_URL</code> / <code>VITE_AI_MODEL</code>); API key is browser-only via this dialog and never baked into the build. See <code>.env.example</code>.</div>
<div class="hint">Budget: ${counts.loadCount}/${counts.perLoad} this load · ${counts.sessionCount}/${counts.perSession} this session. Counters reset on reload / tab close.</div>
<div class="llm-actions">
<button id="llm-cancel">Cancel</button>
<button id="llm-save" class="primary">Save</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-cancel').addEventListener('click', close);
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
overlay.querySelector('#llm-save').addEventListener('click', () => {
const gw = overlay.querySelector('#llm-gw').value.trim();
const model = overlay.querySelector('#llm-model').value.trim();
const key = overlay.querySelector('#llm-key').value;
setLlmConfig({
gatewayUrl: gw || null,
model: model || null,
apiKey: key,
});
close();
setLlmTweakButtonState();
showToast('LLM settings saved', { ok: true, timeout: 2000 });
});
overlay.querySelector('#llm-gw').focus();
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter) {
const diffRows = Object.keys(specAfter || {}).filter((k) => {
if (k === 'seed' || k === 'duration') return false;
return specBefore[k] !== specAfter[k];
}).map((k) => {
const a = specBefore[k];
const b = specAfter[k];
const fmt = (v) => v == null ? '∅' : (typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(2)) : String(v));
return `<div class="kv"><span>${k}</span><b>${fmt(a)} → ${fmt(b)}</b></div>`;
}).join('') || '<div class="hint">No song knobs changed (model returned empty edits — this dialog should not have opened).</div>';
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box llm-diff" role="dialog" aria-modal="true" aria-labelledby="llm-diff-title">
<div class="llm-title" id="llm-diff-title">LLM suggestion — review before applying</div>
<div class="llm-rationale">${escapeHtml(result.rationale || '')}</div>
<div class="llm-confidence hint">confidence ${(result.confidence ?? 0).toFixed(2)}${result.usage ? ` · tokens ${result.usage.totalTokens ?? result.usage.total_tokens ?? '—'}` : ''}</div>
<div class="pp-sub" style="margin-top:10px;">song spec diff</div>
${diffRows}
<div class="pp-sub">effect (approx)</div>
<div class="hint">Re-synthesized and re-analysed locally — what you see when you Apply is what you get.</div>
<div class="llm-actions">
<button id="llm-dismiss">Dismiss</button>
<button id="llm-apply" class="primary">Apply</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-dismiss').addEventListener('click', close);
overlay.querySelector('#llm-apply').addEventListener('click', () => {
try {
const newDuration = specAfter.duration || trackBefore.duration;
// Synthesize a fresh buffer from the edited spec, derive a new FeatureTrack,
// then install it via Show.useTrack so prewarm/arc are correct.
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: newDuration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? (specBefore.seed ?? 1),
});
const newTrack = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
// Derive a seed from the new spec so generateLook is deterministic on the edit.
const newSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
const newLook = generateLook(newTrack, { seed: newSeed });
// Swap in — useTrack handles timeline/provider/arc/prewarm
currentSongSpec = { ...specAfter, seed: newSeed };
state.show.useTrack(newTrack, newLook);
strip.setShow(state.show);
seekTo(0);
renderPanel();
showToast(`Applied LLM tweak: ${result.rationale.slice(0, 120)}`, { ok: true, timeout: 4000 });
} catch (err) {
showToast(`Failed to apply LLM tweak: ${err.message}`);
console.error(err);
} finally {
close();
setLlmTweakButtonState();
}
});
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
// focus Apply for quick keyboard confirm
setTimeout(() => overlay.querySelector('#llm-apply')?.focus(), 50);
}
async function handleLlmTweak() {
if (llmTweakInFlight) return;
if (!state.show || !state.show.ready) {
showToast('Load a track first');
return;
}
const cfg = getLlmConfig();
if (!cfg.isConfigured) {
openLlmConfigDialog();
return;
}
const gate = canInvoke();
if (!gate.ok) {
showToast(gate.detail || 'LLM budget exhausted');
return;
}
// Confirm — preview cost/budget
const remaining = remainingText();
const hint = window.prompt(
`Let the LLM tune the generated song?` +
`\nBudget: ${remaining}` +
`\nModel: ${cfg.model}` +
`\n` +
`\nOptional hint for the model (e.g. "make the drop calmer") — leave empty to skip:`,
''
);
if (hint === null) return; // cancelled
const spec = specForTrack(state.show.track);
if (!spec) {
showToast('No song spec available for this track');
return;
}
// Capture before for diff
const specBefore = { ...spec };
const trackBefore = state.show.track;
const lookBefore = state.show.look;
llmTweakInFlight = true;
setLlmTweakButtonState();
const btn = document.getElementById('btn-llm-tweak');
const prevLabel = btn ? btn.textContent : '';
if (btn) btn.textContent = '… tuning';
// Live progress: start capturing thumbs, then stream. Toast updates as stream arrives.
let llmProgressToast = `Contacting LLM (${cfg.model})…`;
showToast(llmProgressToast, { ok: true });
console.info('[llm] handleLlmTweak start', {
model: cfg.model, gateway: cfg.gatewayUrl,
remaining, hint: hint || '(none)',
specBefore, trackSummary: trackBefore && trackBefore.summary,
lookDirector: lookBefore && lookBefore.director,
});
// Capture 32×32 thumbnails — low-res strip for the vision model, non-blocking.
showToast('Capturing thumbnails…', { ok: true });
let llmFrames = [];
try {
// Keep the preview responsive — allow one paint before capture.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
llmFrames = await captureThumbnails(state.show, { count: 8, size: 32 });
console.info('[llm] captured frames', llmFrames.length, llmFrames.map((f) => `${f.kind}@${f.time.toFixed(1)}s`));
} catch (e) {
console.warn('[llm] captureThumbnails failed', e);
}
showToast(`Contacting LLM (${cfg.model}) · ${llmFrames.length ? llmFrames.length + ' frames' : 'no frames'}…`, { ok: true });
try {
const result = await requestSongTweak({
spec: specBefore,
track: trackBefore,
look: lookBefore,
hint: hint || '',
frames: llmFrames,
config: cfg,
onProgress: (evt) => {
if (!evt) return;
const text = evt.text ? evt.text.slice(0, 80) : '';
if (evt.stage === 'partial') {
const partialKeys = evt.partial && evt.partial.edits ? Object.keys(evt.partial.edits).filter((k) => evt.partial.edits[k] != null) : [];
showToast(`LLM streaming… ${text}${partialKeys.length ? ` · ${partialKeys.join(', ')}` : ''}`, { ok: true });
console.info('[llm] stream', evt);
} else if (evt.stage === 'streaming') {
showToast(`LLM streaming… ${text}`, { ok: true });
}
},
});
// Log what came back before any synthesis — this is the audit trail.
console.groupCollapsed(`[llm] got result — ${Object.keys(result.edits).join(', ') || 'no edits'} · confidence ${String(result.confidence)}`);
console.log('[llm] rationale:', result.rationale);
console.log('[llm] edits:', result.edits);
console.log('[llm] raw:', result.raw, 'usage:', result.usage);
console.groupEnd();
const editsSummary = Object.keys(result.edits).length
? Object.entries(result.edits).map(([k, v]) => `${k}→${v == null ? '∅' : String(v)}`).join(' · ')
: '(no knobs changed)';
showToast(`LLM replied: ${result.rationale.slice(0, 160)} — edits: ${editsSummary}`, { ok: true, timeout: 5000 });
const specAfter = { ...specBefore, ...result.edits };
// Validate locally by re-synthesizing and measuring — show diff before applying.
// Do a lightweight preview synthesis so we can report section/agitation delta
// without mutating the live Show.
let trackAfter = null, lookAfter = null;
try {
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: specAfter.duration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? specBefore.seed ?? 1,
});
trackAfter = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
const previewSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
lookAfter = generateLook(trackAfter, { seed: previewSeed });
console.info('[llm] preview look', lookAfter ? `${lookAfter.director} · ${lookAfter.sections.length} sections` : 'n/a',
trackAfter ? `sections ${trackAfter.sections.length}` : '');
} catch (e) {
console.warn('[llm] preview synthesis failed', e);
}
openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter);
} catch (err) {
console.warn('[llm] request failed', err && err.stack ? err.stack : err);
const code = err && err.code;
if (code === 'NO_EDITS') {
showToast(err.message, { ok: true, timeout: 5000 });
} else if (code === 'BUDGET_PER_LOAD' || code === 'BUDGET_PER_SESSION' || code === 'BUDGET') {
showToast(err.message);
} else if (code === 'AUTH') {
showToast(`LLM auth failed (401) — check your gateway API key in ⚙️\n${err.message}`);
} else if (code === 'RATE_LIMIT') {
showToast(`LLM rate limited (429) — try again shortly\n${err.message}`);
} else if (code === 'NOT_CONFIGURED') {
openLlmConfigDialog();
} else {
showToast(`LLM tweak failed: ${err.message}`);
}
} finally {
llmTweakInFlight = false;
if (btn) btn.textContent = prevLabel || '✨ tune';
setLlmTweakButtonState();
}
}
document.getElementById('btn-llm-tweak')?.addEventListener('click', handleLlmTweak);
document.getElementById('btn-llm-config')?.addEventListener('click', openLlmConfigDialog);
// Keep the tune button in sync with ready/config/budget — renderPanel is
// called after every load/reroll/param change.
const __prevRenderPanel = renderPanel;
renderPanel = function llmAwareRenderPanel(...args) {
const r = __prevRenderPanel.apply(this, args);
setLlmTweakButtonState();
return r;
};
// Initial state — disabled until a track is loaded or a previous session had one
setLlmTweakButtonState();
// Keep loadBankSong in sync with LLM spec tracking
const __origLoadBankSong = loadBankSong;
loadBankSong = async function trackedLoadBankSong(name, seed) {
const spec = SONGS.find((s) => s.name === name);
if (spec) {
currentSongSpec = {
bpm: spec.bpm,
brightness: spec.brightness,
noise: spec.noise,
noiseColour: spec.noiseColour ?? null,
dynamics: spec.dynamics,
transition: spec.transition ?? 0.5,
arrangement: spec.arrangement,
duration: 120,
seed: seed != null ? (Number(seed) >>> 0) : (1 + SONGS.indexOf(spec)),
};
}
return __origLoadBankSong(name, seed);
};
+36
View File
@@ -42,6 +42,28 @@ export const RATE_FLAG = 'rate';
// all.
export const SLOW_AXIS_FLAG = 'slowAxis';
/**
* CONTINUITY RULE: actors and objects can't just disappear during a scene,
* only during camera cuts.
*
* A discrete param is one whose change adds or removes things rather than
* moving them: every `int`/`bool`, plus any float used as a loop bound /
* instance count and marked `discrete: true` (e.g. a float `count` driving
* `if (float(i) >= u_count) break`). Ramping one mid-shot pops members in
* and out one frame to the next; holding it per cue moves the pop under the
* cut, where the venue arrangement salt already lives (see ArcDriver).
*
* Discrete params are therefore held constant for the whole cue: no drift,
* no slow-axis travel, no lookahead ramp, no reactive modulation. They still
* vary per section/variant sample, so a track's drops can be denser than its
* intros — the change just lands on a cut.
*/
export function isDiscreteParam(def) {
if (!def) return false;
if (def.type === 'int' || def.type === 'bool') return true;
return def.discrete === true;
}
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
export const REACTIVE_FEATURES = [
'loudness', 'rms',
@@ -321,6 +343,12 @@ export function validateModule(module) {
if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
errors.push(`${id}: \`readsDepth\` must be boolean`);
}
if (module.venue && typeof module.venue !== 'boolean') {
errors.push(`${id}: \`venue\` must be boolean`);
}
if (module.archetype && !['chamber','stage','ride','dungeon','passage','void'].includes(module.archetype)) {
errors.push(`${id}: unknown venue archetype '${module.archetype}'`);
}
}
const params = module.params || {};
@@ -358,6 +386,10 @@ export function validateModule(module) {
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
}
// Discrete params MAY declare slowAxis: the arc driver steps the
// travel at cuts (section-held journey) instead of sliding it, so
// the count still journeys across the video without popping
// mid-shot. See ArcDriver._paramsAt.
}
}
@@ -376,6 +408,10 @@ export function validateModule(module) {
errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` +
`modulating it jumps phase by elapsed*delta (see RATE_FLAG)`);
}
if (params[name] && isDiscreteParam(params[name])) {
errors.push(`${where}: '${name}' is discrete and cannot be reactive — ` +
`modulating it pops actors mid-shot; counts change only at cuts`);
}
}
return errors;
+3 -125
View File
@@ -1475,7 +1475,7 @@
]
}
},
"Moiré Grid": {
"Moir\u00e9 Grid": {
"coverage": 0.327,
"bedCoverage": 0.02,
"bedCoverageMean": 0.369,
@@ -2451,128 +2451,6 @@
]
}
},
"Pylon Grid": {
"coverage": 0.268,
"bedCoverage": 0.12,
"bedCoverageMean": 0.256,
"variety": 0.125,
"blocks": {
"scale": 0.011,
"orient": 0.12,
"layout": 0.105,
"region": 0.336,
"texture": 0.104,
"motion": 0.074,
"colour": 0.42
},
"readsHistory": false,
"profile": {
"scale": [
0.1378,
0.128,
0.177,
0.2494,
0.3078
],
"orient": [
0.0919,
0.0879,
0.0573,
0.0629,
0.0473,
0.0483
],
"layout": [
0.0369,
0.0666,
0.0609,
0.0395,
0.0947,
0.1125,
0.1056,
0.0906,
0.0531,
0.1186,
0.1214,
0.0614,
0.0092,
0.0106,
0.0114,
0.0069
],
"region": [
-0.2065,
0.0554,
0.122,
0.2793,
0.1446,
0.85,
-0.1406,
0.0724,
0.3754,
0.6814,
0.0554,
0.2293,
0.729,
-0.082,
0.1908,
0.6476,
0.0481,
0.1343,
-0.8264,
-0.0064,
-0.4491,
-0.3436,
-0.2993,
-0.5977,
-0.8201,
0.0117,
-0.855
],
"texture": [
0.5922,
0.1031,
0.1725,
0.0473,
0.2784,
0.3096,
0.0747
],
"colour": [
0.0767,
0.0975,
0.51,
0.2914,
0.0217,
0.0028,
0.4379,
0.1787
],
"motion": [
0.277,
0.1918,
0.1913,
0.1674,
0.1725,
0.0251,
0.0558,
0.0504,
0.0386,
0.0959,
0.0998,
0.1014,
0.1035,
0.0794,
0.1133,
0.1199,
0.0691,
0.0057,
0.0154,
0.0189,
0.008
]
}
},
"Prism Bloom": {
"coverage": 0.085,
"bedCoverage": 0.013,
@@ -5257,7 +5135,7 @@
]
}
},
"Kármán Street": {
"K\u00e1rm\u00e1n Street": {
"coverage": 0.258,
"bedCoverage": 0.047,
"bedCoverageMean": 0.269,
@@ -8550,7 +8428,7 @@
0
]
},
"error": "renders nothing — luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
"error": "renders nothing \u2014 luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
}
}
}
+1 -2
View File
@@ -29,7 +29,7 @@ import { neonCity } from './shader/neon-city.js';
import { flora } from './shader/flora.js';
import { fireflyDrift } from './shader/firefly-drift.js';
import { silkRibbon } from './shader/silk-ribbon.js';
import { pylonGrid } from './shader/pylon-grid.js';
import { prismBloom } from './shader/prism-bloom.js';
import { pitchShatter } from './shader/pitch-shatter.js';
import { auroraVeil } from './shader/aurora-veil.js';
@@ -114,7 +114,6 @@ const MODULES = [
flora,
fireflyDrift,
silkRibbon,
pylonGrid,
prismBloom,
pitchShatter,
+6 -3
View File
@@ -31,7 +31,7 @@ export const analogWow = {
},
reactive: {
dropout: { feature: 'flux', amount: 0.3, response: 'spike' },
dropout: { feature: 'flux', amount: 0.22, response: 'smooth' },
wow: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
},
@@ -74,8 +74,11 @@ vec4 scene(vec2 uv, vec2 p) {
// the frame, and they replace the picture with the tape's own noise floor
// rather than with black — a black band is a cut, this is a fault.
float bandId = floor(p.y * 8.0 + t * 2.0);
float hit = step(1.0 - u_dropout * 0.35, hash11(bandId * 1.7 + floor(t * 3.0)));
float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit;
// Flux window — dropouts cluster rather than striking everywhere at once.
float bandBeatMask = step(0.55, hash11(bandId * 0.73 + floor(u_seed * 0.4))) * focusField(p) * 0.6 + 0.18;
float maskedDropout = u_dropout + bandBeatMask * u_beat * 0.18;
float hit = step(1.0 - maskedDropout * 0.35, hash11(bandId * 1.7 + floor(t * 3.0)));
float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit * (0.5 + 0.5 * bandBeatMask);
vec3 noise = pal(1) * (0.25 + 0.5 * hash12(vec2(uv.x * 400.0, bandId)));
col = mix(col, noise, band * 0.7);
+6 -5
View File
@@ -25,7 +25,7 @@ export const blockMosh = {
reactive: {
bleed: { feature: 'flux', amount: 0.2, response: 'spike' },
burst: { feature: 'beat', amount: 0.25, response: 'smooth' },
burst: { feature: 'beat', amount: 0.13, response: 'smooth' },
},
shader: `
@@ -66,11 +66,12 @@ vec4 scene(vec2 uv, vec2 p) {
float m = clamp(u_bleed * (0.5 + reach), 0.0, 1.0);
vec3 col = mix(base, mosh, m);
// Weak block tint, pulse-pinned to the beat but kept small — the frame as a
// whole never swings in luminance, so it stays clear of the WCAG 2.3.1
// flash ceiling even on a hard four-on-the-floor.
// Weak block tint, pulse-pinned to the beat but kept small — and now
// windowed, so only a subset of blocks brighten rather than the whole frame.
float blk = hash12(cell + st * 7.0);
col += pal(2) * u_burst * (0.5 + 0.5 * blk) * (0.5 + 0.5 * beat);
float blockBeatMask = step(0.62, hash12(cell * 0.31 + floor(u_seed * 0.2))) * focusField(p) * 0.7 + 0.12;
float maskedBurst = u_burst + blockBeatMask * u_beat * 0.22;
col += pal(2) * maskedBurst * (0.5 + 0.5 * blk) * (0.5 + 0.5 * beat * blockBeatMask);
return vec4(inkValue(col), 1.0);
}
@@ -14,7 +14,7 @@ export const fireflyDrift = {
traits: ['camera', 'style'],
params: {
count: { type: 'float', range: [6, 48], default: 22, uniform: 'u_count', bias: 'density' },
count: { type: 'float', range: [6, 48], default: 22, uniform: 'u_count', bias: 'density', discrete: true },
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
jitter: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_jitter' },
spread: { type: 'float', range: [0.4, 1.3], default: 0.95, uniform: 'u_spread' },
+12 -8
View File
@@ -16,7 +16,11 @@ export const flora = {
traits: ['shape', 'camera', 'style'],
params: {
count: { type: 'float', range: [1, 5], default: 3, uniform: 'u_count', bias: 'density' },
// Cost note: every petal and bloom calls castForm (full SDF assembly),
// so worst case was 5 stalks x (12 stem + 6 petals + 6 blooms) = 120
// SDF evals per pixel — measured ~12x its neighbours. Trimmed to
// 4 x (8 + 4 + 4) = 64: same silhouette, half the cost.
count: { type: 'float', range: [1, 4], default: 3, uniform: 'u_count', bias: 'density', discrete: true },
height: { type: 'float', range: [0.6, 2.0], default: 1.1, uniform: 'u_height', bias: 'energy' },
stem: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_stem' },
sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' },
@@ -62,7 +66,7 @@ vec4 scene(vec2 uv, vec2 p) {
// Soft vertical wash behind the plants.
vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5));
for (int i = 0; i < 5; i++) {
for (int i = 0; i < 4; i++) {
if (i >= int(u_count)) break;
float fi = float(i);
float s = u_seed + fi * 43.7;
@@ -72,8 +76,8 @@ vec4 scene(vec2 uv, vec2 p) {
// Stem, sampled so the bend follows the sway curve rather than a chord.
float stemMask = 0.0;
for (int j = 0; j < 12; j++) {
float hj = (float(j) + 0.5) / 12.0;
for (int j = 0; j < 8; j++) {
float hj = (float(j) + 0.5) / 8.0;
float cx = baseX + swayAt(hj, s, t) * u_sway;
float cy = baseY + hj * height;
float d = length(p - vec2(cx, cy));
@@ -87,9 +91,9 @@ vec4 scene(vec2 uv, vec2 p) {
float tipY = baseY + height;
// Petals up the upper half, alternating sides.
for (int l = 0; l < 6; l++) {
for (int l = 0; l < 4; l++) {
float fll = float(l);
float node = 0.45 + floor(fll * 0.5) * 0.12;
float node = 0.45 + floor(fll * 0.5) * 0.18;
float side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0;
float nx = baseX + swayAt(node, s, t) * u_sway;
float ny = baseY + node * height;
@@ -103,8 +107,8 @@ vec4 scene(vec2 uv, vec2 p) {
// Bloom: a small corona of signature forms around the crown.
float bloom = 0.0;
for (int k = 0; k < 6; k++) {
float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s;
for (int k = 0; k < 4; k++) {
float a = 6.2831853 * (float(k) + 0.5) / 4.0 + s;
vec2 off = vec2(cos(a), sin(a)) * u_leaf * (0.30 + 0.2 * beat);
bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
}
@@ -31,7 +31,7 @@ export const gateCorridor = {
},
reactive: {
lamps: { feature: 'beat', amount: 0.35, response: 'spike' },
lamps: { feature: 'beat', amount: 0.14, response: 'smooth' },
aperture: { feature: 'bandLow', amount: 0.15, response: 'smooth' },
},
@@ -75,10 +75,14 @@ vec4 scene(vec2 uv, vec2 p) {
col += tint * frame * (0.25 + near * 0.75);
col += tint * exp(-d * 14.0) * near * 0.2;
// Beat window — only every third gate's lamp carries the beat,
// and it is masked by focal proximity so the corridor does not flash as one.
float gateBeatMask = step(0.68, hash12(vec2(fi * 0.41, floor(u_seed * 0.3)))) * focusField(p) * 0.9 + 0.1;
float maskedLamps = u_lamps + gateBeatMask * u_beat * 0.45;
// A lamp at the top of every third gate, pulsing on the beat.
if (mod(fi, 3.0) < 0.5) {
vec2 lampAt = vanishAt + vec2(0.0, scale);
col += pal(4) * exp(-length(p - lampAt) * 26.0) * u_lamps * (0.3 + near);
col += pal(4) * exp(-length(p - lampAt) * 26.0) * maskedLamps * (0.3 + near) * (0.6 + 0.4 * gateBeatMask);
}
}
@@ -13,7 +13,7 @@ export const horizonLines = {
traits: ['space', 'camera', 'style'],
params: {
count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' },
count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density', discrete: true },
thickness: { type: 'float', range: [0.002, 0.03], default: 0.008, uniform: 'u_thickness' },
bend: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_bend' },
speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
+6 -2
View File
@@ -23,7 +23,7 @@ export const metaballs = {
reactive: {
radius: { feature: 'bandLow', amount: 0.25 },
rim: { feature: 'beat', amount: 0.3, response: 'spike' },
rim: { feature: 'beat', amount: 0.12, response: 'smooth' },
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
},
@@ -64,7 +64,11 @@ vec4 scene(vec2 uv, vec2 p) {
vec3 col = pal(0) * 0.06;
col = mix(col, tint, surface);
col += tint * rim * u_rim;
// Beat window — rim brightens only where a blob is near a focal point,
// so the highlight is a spot travelling over the surface, not a global wash.
float metabBeatMask = clamp(focusField(p) * 1.3, 0.0, 1.0) * 0.7 + 0.18;
float maskedRim = u_rim + metabBeatMask * u_beat * 0.45;
col += tint * rim * maskedRim;
return vec4(inkValue(col), 1.0);
}
@@ -23,7 +23,7 @@ export const pitchShatter = {
},
reactive: {
amp: { feature: 'beat', amount: 0.4, response: 'spike' },
amp: { feature: 'beat', amount: 0.14, response: 'smooth' },
bleed: { feature: 'flux', amount: 0.25, response: 'smooth' },
},
@@ -41,7 +41,10 @@ vec4 scene(vec2 uv, vec2 p) {
// step, decided by a hash of (slice, step) so it jumps cleanly on the grid.
float slice = floor(uv.x * u_slices);
float sliceRand = hash12(vec2(slice, step_ * 31.0));
float pitch = (sliceRand - 0.5) * 2.0 * u_amp;
// Beat window — only a vertical band shuffles, not the whole screen.
float sliceBeatMask = step(0.62, hash12(vec2(slice * 0.27, floor(u_seed * 0.5 + step_ * 0.11)))) * (0.5 + 0.5 * smoothstep(0.9, 0.2, length(p)));
float maskedAmp = u_amp + sliceBeatMask * u_beat * 0.18;
float pitch = (sliceRand - 0.5) * 2.0 * maskedAmp;
// The base image is always sampled from the un-pitched field so the scene
// has real content behind the displacement, even on its first frame.
@@ -56,7 +59,7 @@ vec4 scene(vec2 uv, vec2 p) {
// The smear: pull the previous frame across the pitch so slices leave a
// transient ghost as they land, and the corruption feels sludgy.
vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55)));
vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55 * (0.5 + 0.5 * sliceBeatMask))));
col = mix(col, ghost, u_bleed * (0.4 + sliceRand));
// A thin scan beam rooting the hard edge of each step, kept local and beat
-176
View File
@@ -1,176 +0,0 @@
// Structural family: a field of standing pylons receding to the track's
// horizon. The columns converge on a vanishing point, giving real perspective
// depth, and each column carries the song's own object on top, so a hexagonal
// track grows hexagonal pylons. The beat walks a pulse of light down the rows
// rather than flashing the whole frame.
//
// Nothing here is the scene's own invention any more. The crown is the song's
// protagonist and the LEG is a stack of its chorus: a column of bodies holding
// the subject up, rather than the thin fixed-width strut this drew for years.
// That strut was the same object in every video — a scene that has migrated its
// subject and kept its structure is still telling you what IT is, in the half of
// the frame that is structure, and next to a marched crown it read as scaffolding
// left up around a finished building.
//
// The crowns are SOLID. This scene already had the one thing the 3D cast needs
// and most scenes lack — a real depth axis, with near rows large and far rows
// small — so a flat crown was the only part of it drawn without perspective: a
// sticker facing the viewer on top of a receding structure. Marching them puts
// the object in the same space as the field it stands in, and because each
// pylon turns its own crown at its own angle, one field shows the song's form
// from a dozen sides at once. See castSolid.
//
// WHAT THE NUMBERS SAY, since two of them disagree and both are right.
//
// Crop to the bottom third of the frame — the near rows, where the columns are
// tall and the crowns are not — and measure how far apart six identities are:
// 13.4/255 with the old strut, 22.2/255 with the chorus stacked. That is the
// complaint answered. The strut was a fixed width and a fixed shape, so the only
// thing separating one video's structure from another's was the palette.
//
// The whole-frame variety score barely moves for it: 0.1322 to 0.1348 over
// 8 songs x 8 seeds. That is honest rather than disappointing — the crowns
// already dominate a frame-level descriptor, and a measurement averaged over the
// whole image cannot see a change confined to the lower third. It is the reason
// the crop above exists.
//
// The frame score is also still under the flat version's 0.141, and the cause is
// known: a body always fills its own silhouette, where a stamped cast form paints
// anywhere between an outline and a disc depending on whether the identity is
// hollow, and that swing was worth three hundredths of LAYOUT variety. Two
// candidate fixes were tried and both measured flat — keeping the far rows
// stamped (0.132), and running the lit surface through inkPattern (0.132 here,
// and 0.0009 on Effigy). It is the solid itself, and the trade is deliberate:
// the score does not measure a crown that ignores the perspective of the field
// it stands in, or a leg that is the same object in every video.
export const pylonGrid = {
name: 'Pylon Grid',
family: 'structural',
kind: 'fragment',
// Personality: see look/Personality.js.
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
texture: 0.4,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'style'],
params: {
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density' },
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
rows: { feature: 'bandLow', amount: 0.25 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float hy = sigHorizonY();
vec3 col = pal(0) * 0.05;
// Whether a crown already owns this pixel. Rows are walked near to far, so
// first-wins is the correct occlusion here rather than merely the cheap one
// — and it bounds a hundred and sixty instances to about one march.
float painted = 0.0;
for (int r = 0; r < 10; r++) {
if (float(r) >= u_rows) break;
float fr = float(r);
// Perspective: the last row sits on the horizon, the first at the bottom
// of the frame, everything between scaling by its depth fraction.
float f = fr / max(u_rows - 1.0, 1.0); // 0 near .. 1 far
float y0 = mix(-1.08, hy + 0.06, f); // row baseline
float scale = mix(1.0, 0.12, f); // perspective shrink
float depthShade = mix(1.0, 0.35, f); // far rows dim
for (int c = 0; c < 16; c++) {
if (float(c) >= u_columns) break;
float fc = float(c);
float cx = (fc / max(u_columns - 1.0, 1.0) - 0.5) * 2.0 * u_spread * scale;
float h = u_height * scale;
float yTop = y0 + h;
// The beat pulse walks a glow down the depth axis, row by row, so the
// light travels rather than strobing the whole frame.
float walk = sat(1.0 - abs(fr - mod(u_beatPhase * 6.0 + t * 0.4, u_rows)));
float inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * u_beat);
// The crown is resolved BEFORE the leg, because the two overlap
// where the object sits on the column and the crown has to win it —
// resolved the other way round, every subject is cut off at the
// waist by the thing holding it up.
//
// The song's object, standing on the pylon.
vec2 crown = vec2(cx, yTop);
float size = (0.06 + 0.2 * scale) * (1.0 + u_beat * 0.4);
vec2 local = (p - crown) / max(size, 1e-3);
if (dot(local, local) < 1.6 && painted < 0.5) {
// Each crown at its own angle, so the field is one form seen
// from many sides rather than one silhouette repeated. The turn
// is slow and per-pylon; the row term keeps a receding line of
// them from presenting the same profile all the way back.
mat3 turn = castTurn(t * 0.6 + fc * 0.9 + fr * 0.4, 0.2 + sin(t + fr) * 0.2);
vec3 n;
float hit = castSolid(local, turn, n);
if (hit > 0.0) {
painted = 1.0;
col = mix(col, castLit(n, vec3(0.0, 0.0, 1.0)) * inten * (0.6 + u_pulse), 0.92);
}
// Its outline in the track's line weight, which is what carries
// the far rows: at twelve percent scale a crown is a few pixels
// and the lit faces are gone before the edge is.
float d = castSDF3(turn * vec3(local, 0.0)) * size;
col += pal(3) * inkStroke(d) * inten * (0.5 + u_pulse);
}
col += pal(int(mod(fr, 4.0))) * exp(-dot(p - crown, p - crown) * 60.0) * u_pulse * depthShade;
// THE LEG: the chorus, stacked. How many members make up a column
// is the song's element size deciding it — a track built of a few
// huge forms stands on three of them, a track of many small ones on
// a ladder of nine — and how WIDE the column is comes from the
// chorus's own proportions, which is why no two songs get the same
// pylon. See Identity.js: this is what the chorus solid is for.
float segs = clamp(floor(2.0 + 3.6 / max(stageScale(), 0.3)), 2.0, 9.0);
float member = max(h / (segs * 2.0), 1e-3);
// The bounding test is the column's own box, so a pixel outside it
// costs two comparisons rather than a march. With a hundred and
// sixty pylons on screen that test is most of the scene's budget.
if (abs(p.x - cx) < member * 1.3 && p.y > y0 - member * 0.3
&& p.y < yTop + member * 0.3 && painted < 0.5) {
float rung = floor((p.y - y0) / (member * 2.0));
float centreY = y0 + (rung + 0.5) * member * 2.0;
vec2 legLocal = vec2(p.x - cx, p.y - centreY) / member;
// Every member at its own angle, so a column is a stack of one
// form seen many ways rather than one form printed nine times.
mat3 legTurn = castTurn(t * 0.35 + rung * 1.1 + fc * 0.5, rung * 0.5);
vec3 ln;
float legHit = castChorusSolid(legLocal, legTurn, ln);
if (legHit > 0.0) {
painted = 1.0;
col = mix(col, castLit(ln, vec3(0.0, 0.0, 1.0))
* mix(inten, 1.0, 0.25) * pal(int(mod(fc, 4.0))) * 1.6, 0.9);
}
col += pal(int(mod(fc, 4.0)))
* inkStroke(castChorus3(legTurn * vec3(legLocal, 0.0)) * member) * inten * 0.8;
}
}
}
// Ground haze and the shared air between here and the horizon.
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y - hy)));
return vec4(inkValue(col), 1.0);
}
`,
};
+6 -2
View File
@@ -32,7 +32,7 @@ export const scaleMosaic = {
reactive: {
sheen: { feature: 'bandHigh', amount: 0.35, response: 'smooth' },
lift: { feature: 'beat', amount: 0.2, response: 'spike' },
lift: { feature: 'beat', amount: 0.12, response: 'smooth' },
},
shader: `
@@ -52,10 +52,14 @@ vec4 scene(vec2 uv, vec2 p) {
vec2 cell = floor(q);
vec2 f = fract(q) - 0.5;
// Beat window — only scales near the focus lift on the beat, so the
// phyllotaxis breathes in a patch rather than as one sheet.
float scaleBeatMask = clamp(focusField(p) * 1.1, 0.0, 1.0) * step(0.35, hash12(cell * 0.33 + floor(u_seed * 0.2))) * 0.8 + 0.12;
float maskedLift = u_lift + scaleBeatMask * u_beat * 0.35;
// Overlap: the scale is drawn slightly larger than its cell, so each one
// laps over the one below and the surface reads as layered rather than tiled.
float lift = sin(cell.y * 1.7 + cell.x * 0.9 + u_barPhase * 6.2831 + hash12(cell) * 6.0) * u_lift;
float lift = sin(cell.y * 1.7 + cell.x * 0.9 + u_barPhase * 6.2831 + hash12(cell) * 6.0) * maskedLift;
float breath = 1.0 + sin(u_barPhase * 6.28318530718) * u_breathe;
float rad = u_size * breath * (1.0 + lift * 0.3);
+13 -5
View File
@@ -25,8 +25,8 @@ export const scanTear = {
},
reactive: {
shift: { feature: 'beat', amount: 0.5, response: 'spike' },
tear: { feature: 'flux', amount: 0.35, response: 'spike' },
shift: { feature: 'beat', amount: 0.14, response: 'smooth' },
tear: { feature: 'flux', amount: 0.22, response: 'smooth' },
slices:{ feature: 'bandHigh', amount: 0.25 },
},
@@ -40,12 +40,20 @@ vec4 scene(vec2 uv, vec2 p) {
float row = floor(uv.y * u_slices);
float rowRandom = hash12(vec2(row, step_));
// Beat windowing — whole-screen shuffle reserved for special cases.
// Only a subset of rows carries the beat, so the tear occupies a band
// rather than the full frame. Region motion stays lively; whole-screen
// stays calm. See look/comfort.js CANVAS_BEAT_CAP.
float rowBeatMask = step(0.68, hash12(vec2(row * 0.31, floor(u_seed * 0.7 + step_ * 0.13)))) * focusField(p) * 0.7 + 0.15 * (1.0 - focusField(p));
// Effective beat contribution is masked; base u_shift already capped to
// 0.14 in Layer.js, extra masked term replaces the former 0.5 spike.
float maskedShift = u_shift + rowBeatMask * u_beat * 0.18;
// Only some rows tear, and only above the tear threshold.
// Slice weight follows the track's line weight — the same art direction
// that thickens a grid line thickens a tear.
float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom);
float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn;
float offset = (rowRandom - 0.5) * 2.0 * maskedShift * torn;
// The rows tear in SCREEN space — that is what a tear is — but the image
// being torn is filmed at the shot's framing. See framedUv.
@@ -70,9 +78,9 @@ vec4 scene(vec2 uv, vec2 p) {
// Smear the previous frame along the displacement.
vec3 ghost = prev(vec2(fract(uv.x + offset * 0.6), uv.y));
col = max(col, ghost * u_persist);
col = max(col, ghost * u_persist * (0.6 + 0.4 * rowBeatMask));
col += pal(4) * torn * u_shift * 0.6;
col += pal(4) * torn * maskedShift * 0.35 * rowBeatMask;
return vec4(inkValue(col), 1.0);
}
`,
@@ -31,7 +31,6 @@ export const stairwellDescent = {
reactive: {
lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
rails: { feature: 'bandMid', amount: 0.15, response: 'smooth' },
},
shader: `
+6 -3
View File
@@ -28,7 +28,7 @@ export const tideRings = {
},
reactive: {
caustic: { feature: 'beat', amount: 0.3, response: 'smooth' },
caustic: { feature: 'beat', amount: 0.14, response: 'smooth' },
wavelength: { feature: 'bandMid', amount: 0.2 },
},
@@ -60,6 +60,9 @@ vec4 scene(vec2 uv, vec2 p) {
}
float wave = sum / max(energy, 1e-3);
// Beat windowing — caustic brightens only where the song is looking.
float tideBeatMask = clamp(focusField(p) * 1.2, 0.0, 1.0) * smoothstep(1.2, 0.3, length(p));
float maskedCaustic = u_caustic + tideBeatMask * u_beat * 0.5;
// The node lattice: where the sum passes through zero, the surface is flat
// and bright. Squaring the gradient-ish term is what picks those out.
@@ -67,8 +70,8 @@ vec4 scene(vec2 uv, vec2 p) {
nodes = pow(sat(nodes), 3.0);
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
col += pal(3) * nodes * u_caustic;
col += pal(4) * pow(sat(wave), 6.0) * u_caustic * 0.5;
col += pal(3) * nodes * maskedCaustic;
col += pal(4) * pow(sat(wave), 6.0) * maskedCaustic * 0.5;
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
return vec4(inkValue(col), 1.0);
+2
View File
@@ -118,6 +118,7 @@ export const assembly = {
}
instance.heroPaletteMap = actorSpec.paletteMap || [];
instance.heroBuilt = true;
instance.needsCompile = true;
}
// --- satellites: small instances so count/spread visibly matter ---
@@ -173,6 +174,7 @@ export const assembly = {
}
instance.satBuilt = true;
instance.satCount = needCount;
instance.needsCompile = true;
}
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
+8 -12
View File
@@ -1,12 +1,9 @@
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
//
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
// scale foreshortening. Crowns are the protagonist assembly via actorToGeometry,
// legs are chorus rungs, ground is a plane at sigHorizonY level. The palette and
// lattice are the same data the flat stage consumes.
// A rows×columns grid of chorus-stacked legs (segs from stageScale) with a
// beat-walk pulse — as BufferGeometry with real occlusion, parallax and scale
// foreshortening. Crowns are the protagonist assembly via actorToGeometry,
// legs are chorus rungs, ground is a plane at sigHorizonY level.
//
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
@@ -26,8 +23,8 @@ export const pylonField3D = {
texture: 0.4,
params: {
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true },
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true, discrete: true },
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density', discrete: true },
spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
@@ -37,7 +34,6 @@ export const pylonField3D = {
reactive: {
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
rows: { feature: 'bandLow', amount: 0.25 },
},
build({ scene, seed, params, actorSpec, THREE }) {
@@ -87,7 +83,6 @@ export const pylonField3D = {
const t = timeline.time;
const beat = features.beat || 0;
const beatPhase = features.beatPhase || 0;
const bandLow = features.bandLow || 0;
const identity = personality ? personality.identity : null;
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
@@ -132,7 +127,7 @@ export const pylonField3D = {
scene.fog.color.setRGB(c[0] * (0.08 + wash * 0.15), c[1] * (0.08 + wash * 0.15), c[2] * (0.12 + wash * 0.18));
}
const cols = Math.max(3, Math.min(16, Math.round(params.columns + bandLow * 0.1)));
const cols = Math.max(3, Math.min(16, Math.round(params.columns)));
const rows = Math.max(2, Math.min(10, Math.round(params.rows)));
const spread = Math.max(0.4, params.spread) * (0.7 + latticeSpread * 0.6) * (latticeKind === 'radial' ? 1.15 : latticeKind === 'scatter' ? 0.92 : 1);
const height = Math.max(0.2, params.height);
@@ -303,6 +298,7 @@ export const pylonField3D = {
instance.builtRows = rows;
instance.builtIdentityKey = identityKey;
instance.pylonCount = cols * rows;
instance.needsCompile = true;
}
// Palette + opacity live rebind so paletteArc is visible.
@@ -260,6 +260,7 @@ export const synthwaveCorridor = {
instance.heroGroup.add(m);
}
instance.heroBuilt = true;
instance.needsCompile = true;
}
// Passers — rebuilt only when count changes or identity changes materially.
@@ -306,6 +307,7 @@ export const synthwaveCorridor = {
instance.passerGroup.add(mesh);
}
instance.passerCount = passerCount;
instance.needsCompile = true;
} else if (passerCount === 0) {
for (const child of [...instance.passerGroup.children]) {
instance.passerGroup.remove(child);
+16
View File
@@ -132,7 +132,19 @@ const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || nul
*/
const ASSUMED_COVERAGE = 0.15;
export function isVenue(module) {
return !!(module && module.venue);
}
export function venueCoverage(module) {
// Venues are room/stage planes that fill the frame by construction.
// Hard-coded so they pass GROUND_MIN without a gallery measurement.
if (!isVenue(module)) return coverageOf(module);
return 0.72;
}
export function coverageOf(module) {
if (isVenue(module)) return 0.72;
const row = ROW(module);
return row ? row.coverage : ASSUMED_COVERAGE;
}
@@ -143,6 +155,7 @@ export function coverageOf(module) {
* asks. See GROUND_BIAS for why the two numbers differ so much.
*/
export function groundCoverageOf(module) {
if (isVenue(module)) return 0.72;
const row = ROW(module);
if (!row) return ASSUMED_COVERAGE;
if (row.bedCoverageMean !== undefined) return row.bedCoverageMean;
@@ -163,6 +176,7 @@ export function groundCoverageOf(module) {
* an occasional dim intro. So: fills the frame on average, and never vanishes.
*/
export function groundFloorCoverageOf(module) {
if (isVenue(module)) return 0.62;
const row = ROW(module);
if (!row) return ASSUMED_COVERAGE;
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
@@ -191,6 +205,8 @@ export function surfaceOf(module) {
* tolerance of 4.
*/
export function canGround(module) {
if (isVenue(module)) return false;
if (module && module.readsDepth) return false;
return isMeasured(module)
&& surfaceOf(module) === 'canvas'
&& groundCoverageOf(module) >= GROUND_MIN
+79
View File
@@ -0,0 +1,79 @@
// Clickable previews: every rendered thumbnail links back to the app frame
// that produced it.
//
// A preview is exact when the app rebuilds the same pixels from the URL,
// which takes five agreements, all enforced here and in the pages that link:
//
// song + seed the look (bank PCM, so the file IS the synth — see
// audio/songbank.js bankPcm and the fixed-rate, exact WAV
// decode in audio/decode.js)
// frame the landing frame (?frame=, warm seek)
// venue/venueSel the pinned room shell (shared src/venues/pin.js)
// viz the pinned subject (shared src/look/pinViz.js)
// warm the probe's warm-up length (?warm=): feedback converges
// geometrically, so different warm-up lengths land on
// differently converged state
//
// Both sides prime programs first (app deep-link loads; probe harnesses),
// because a draw against an unlinked program is wrong output that feedback
// then carries for the rest of the shot.
//
// Gallery cells are the deliberate exception: they hold one scene fixed
// outside any look, so no app URL reproduces them — don't wrap those.
//
// Pure module: no DOM at import time, no WebGL, no wall-clock. The URL
// builder is node-testable; previewLink needs a document.
/**
* App URL for the exact frame a preview shows.
*
* @param {object} spec
* @param {string} spec.song bank song name
* @param {number} spec.seed look seed (unsigned int)
* @param {number} spec.frame frame index
* @param {boolean} [spec.venue] venue on (app ?venue=1)
* @param {string} [spec.venueSel] pinned venue shell (implies venue)
* @param {string} [spec.viz] pinned viz subject (app ?viz=)
* @param {number} [spec.warm] probe warm-up frames (app ?warm=)
*/
export function appFrameUrl({ song, seed, frame, venue = false, venueSel = null, viz = null, warm = null }) {
const p = new URLSearchParams();
p.set('song', song);
p.set('seed', String(seed >>> 0));
p.set('frame', String(Math.max(0, Math.round(frame))));
if (venueSel) {
p.set('venue', '1');
p.set('venueSel', venueSel);
} else if (venue) {
p.set('venue', '1');
}
if (viz) p.set('viz', viz);
if (Number.isFinite(warm)) p.set('warm', String(Math.max(0, Math.round(warm))));
return `/?${p}`;
}
/** Human caption for the link title: song · seed · m:ss · venue · viz. */
export function previewTitle({ song, seed, frame, venue = false, venueSel = null, viz = null, fps = 60 }) {
const t = Math.max(0, Math.round(frame)) / fps;
const mm = Math.floor(t / 60);
const ss = String(Math.floor(t % 60)).padStart(2, '0');
const where = venueSel ? `venue ${venueSel}` : (venue ? 'venue' : 'no venue');
const what = viz ? ` · viz ${viz}` : '';
return `${song} · seed ${seed >>> 0} · frame ${Math.round(frame)} (${mm}:${ss}) · ${where}${what} — open this exact frame in the app`;
}
/**
* Wrap a rendered canvas so clicking it opens the exact app frame.
* Returns the anchor (wrapping the canvas); the canvas keeps its size.
*/
export function previewLink(canvas, spec) {
const a = document.createElement('a');
a.href = appFrameUrl(spec);
a.target = '_blank';
a.rel = 'noopener';
a.title = previewTitle(spec);
a.className = 'preview-link';
canvas.replaceWith(a);
a.appendChild(canvas);
return a;
}
+15
View File
@@ -284,6 +284,21 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
}
}
/* LLM gateway dialogs (main app). Minimal, inherits panel/modal language. */
.llm-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 50; }
.llm-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); }
.llm-box { position: relative; width: min(560px, calc(100vw - 32px)); max-height: min(78vh, 720px); overflow: auto; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); padding: 16px 18px 14px; box-shadow: 0 20px 40px rgba(0,0,0,0.55); }
.llm-box.llm-diff { width: min(640px, calc(100vw - 32px)); }
.llm-title { font-weight: 700; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.llm-field { display: grid; grid-template-columns: 110px 1fr; gap: 8px; align-items: center; margin: 8px 0; font-size: 12px; }
.llm-field span { color: var(--dim); }
.llm-field input { width: 100%; background: #171a22; border: 1px solid var(--line); color: var(--text); padding: 6px 8px; font: inherit; font-size: 12px; }
.llm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
.llm-rationale { font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: var(--text); margin-bottom: 6px; }
.llm-confidence { margin-bottom: 4px; }
.llm-not-configured { opacity: 0.85; }
#btn-llm-tweak:disabled { opacity: 0.45; cursor: not-allowed; }
/* Single-scene gate output (checks.html?scene=Name). Monospaced so the
PASS/FAIL column lines up and the whole verdict reads in one glance. */
.scene-gate {
+205
View File
@@ -0,0 +1,205 @@
// Pure, no THREE — produces VenueSpec (data, not scene graph).
// Same contract as ActorGenerator: audio tilts centre, forked rng picks inside,
// never Math.random / wall-clock, seek === playback.
import { signatureWeight } from '../look/Personality.js';
import { venues, ARCHETYPES } from './registry.js';
import { genDungeon } from './dungeon/map.js';
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Generate one venue — the place the track lives.
* Venue is higher-level than viz: picked first, viz comes second.
* Census-like: registry has traits, picked via signatureWeight.
* @param {object} opts.summary FeatureTrack.summary
* @param {import('../engine/rng.js').Rng} opts.rng forked rng.fork('venue')
* @param {object} [opts.personality] look.personality (signature, shape, space, etc.)
* @param {object} [opts.director] look.director
* @param {object} [opts.story] look.story
* @param {Array} [opts.sections] track.sections (for perSection)
* @returns {object} VenueSpec
*/
export function generateVenue({ summary = {}, rng, personality = null, director = null, story = null, sections = null }) {
if (!rng) throw new Error('generateVenue: rng required');
const signature = personality ? personality.signature : [];
// Census pick — weighted by signature just like scenes
let weights = venues.map((m) => signatureWeight(m, signature));
// Audio tilts archetype centre softly (not a rule): stillness/bpm/flatness → archetype weights handled via trait->signature correlation;
// add mild director lean if present
const s = summary || {};
const stillness = personality && personality.motion ? personality.motion.stillness : 0.5;
const fast = clamp01(((s.bpm ?? 120) - 80) / 80);
const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3);
// Sad slow → chamber (stillness high + slow), pumping → stage (fast + noisy),
// driving songs → ride (fast tempo + high energy + low stillness: a song
// that moves wants a vehicle). Tilt, not decide.
const tilt = {};
const drive = clamp01(((s.bpm ?? 120) - 100) / 60) * (1 - stillness * 0.7);
for (let i = 0; i < venues.length; i++) {
const v = venues[i];
let w = weights[i];
if (v.archetype === 'chamber') w *= 0.7 + stillness * 0.8 + (1 - fast) * 0.35;
if (v.archetype === 'stage') w *= 0.6 + fast * 0.6 + noisy * 0.35;
if (v.archetype === 'ride') w *= 0.35 + drive * 1.4;
// Dark, slow songs want a crypt: slowness gates the whole tilt, then
// stillness and darkness push inside it. Fast songs barely feel it.
// Tilt, not decide.
if (v.archetype === 'dungeon') {
const slow = clamp01((110 - (s.bpm ?? 120)) / 50);
const dark = 1 - Math.min(1, (s.meanCentroid ?? 0.5) * 1.4);
w *= 0.12 + slow * (0.3 + stillness * 1.0 + dark * 0.5);
}
tilt[v.name] = w;
weights[i] = w;
}
// Ensure at least one weight > 0
const totalW = weights.reduce((a, b) => a + Math.max(0, b), 0);
if (totalW <= 0) weights = venues.map(() => 1);
const picked = rng.pickWeighted(venues, weights);
const seed = rng.int(0, 0x7fffffff);
// Shell — kind & scale vary per picked archetype but stay simple in V1
const shell = {
kind: picked.archetype,
scale: 1,
wallHeight: picked.archetype === 'chamber' ? 6 : picked.archetype === 'ride' ? 2.4 : 3.2,
wallDist: picked.archetype === 'chamber' ? 11 : picked.archetype === 'ride' ? 26 : 20,
};
// Projection — hybrid per archetype. The ride's screen is the car radio:
// small, bright, watched up close. The dungeon's is the portal stone:
// architectural, bright, approached on foot.
const projection = picked.archetype === 'chamber'
? { kind: 'window', uvScale: 1, brightness: 0.55 }
: picked.archetype === 'ride'
? { kind: 'radio', uvScale: 1, brightness: 0.9 }
: picked.archetype === 'dungeon'
? { kind: 'portal', uvScale: 1, brightness: 0.9 }
: { kind: 'screen', uvScale: 1, brightness: 0.75 };
// Population — lattice-like, deterministic count/spread/jitter. The ride
// seats actor passengers (2-4), not a crowd; the dungeon walks a delving
// party (2-4), not a crowd either.
const latticeKinds = ['grid', 'scatter', 'radial'];
const pRng = rng.fork(`venue:pop:${picked.name}`);
const population = picked.archetype === 'ride'
? { kind: 'row', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
: picked.archetype === 'dungeon'
? { kind: 'party', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
: {
kind: pRng.pick(latticeKinds),
count: pRng.int(picked.archetype === 'chamber' ? 3 : 4, picked.archetype === 'chamber' ? 9 : 12),
spread: pRng.range(0.5, 0.85),
jitter: pRng.range(0.15, 0.45),
};
// PaletteMap — which palette entry each kit piece reads (seeded)
const paletteMap = [];
const mapLen = population.count;
for (let i = 0; i < mapLen; i++) paletteMap.push(pRng.int(0, 3));
// Per-section evolution — discrete, from Story (setup/turn/climax/resolution).
// These are the venue's REST values per section: the ArcDriver interpolates
// between them across each boundary (see _venueStateAt), so the room drifts
// instead of stepping. A section may still JUMP the room — see rearrangeAt
// below — but only with a camera cut to cover it.
const n = sections ? sections.length : (story && story.sections ? story.sections.length : 0);
const perSection = [];
for (let i = 0; i < n; i++) {
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
if (!state) {
perSection.push({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 });
continue;
}
const act = state.act;
const tension = state.tension ?? 0.5;
let light = 0.65;
let fog = 0.35;
let popMul = 0.6;
let reachMul = 0.9;
if (act === 'climax') { light = 0.92; fog = 0.18; popMul = 1.0; reachMul = 1.2; }
else if (act === 'turn') { light = 0.78; fog = 0.28; popMul = 0.85; reachMul = 1.0; }
else if (act === 'setup') { light = 0.52; fog = 0.45; popMul = 0.35; reachMul = 0.7; }
else if (act === 'resolution') { light = 0.48; fog = 0.55; popMul = 0.45; reachMul = 0.65; }
else if (act === 'development') { light = 0.68 + tension * 0.15; fog = 0.32; popMul = 0.6 + tension * 0.2; reachMul = 0.85 + tension * 0.2; }
perSection.push({ light: clamp01(light), fog: clamp01(fog), popMul: clamp01(popMul), reachMul: clamp01(reachMul) });
}
// Story rearrangements — the ONLY sanctioned jumps. Decided here at
// generation time from the story (never mid-render): when the song moves
// to a new act, the room may re-hang itself — new furniture layout, new
// camera side — and the camera MUST cut on the same frame to cover it
// (see ArcDriver venueJumpAt / Camera.relocatesAt). Furniture never swaps
// mid-act: within an act the room only drifts, so objects persist.
//
// Rationed to ONE re-hang per video (plus the recap return): each jump is
// a discontinuity no arc can place, and measured three of them shuffle a
// video flat while one reads as an event. The re-hang lands on the
// strongest act change (climax preferred); the outro returns to the
// intro's arrangement (recap is a return, not a redecoration). Seeded,
// stable, serialisable — part of the spec.
const rearrangeAt = [];
{
const acts = [];
for (let i = 0; i < n; i++) {
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
acts.push(state ? state.act || 'development' : 'development');
}
// Rank act changes: climax first, then turn, then any other change.
const rank = (a) => a === 'climax' ? 0 : a === 'turn' ? 1 : 2;
let hangAt = -1, hangRank = 99;
for (let i = 1; i < n; i++) {
if (acts[i] === acts[i - 1]) continue;
const r = Math.min(rank(acts[i]), rank(acts[i - 1]) + 0.5);
if (r < hangRank) { hangRank = r; hangAt = i; }
}
let arrangement = 0;
for (let i = 0; i < n; i++) {
const isRecap = story && story.recap
&& sections && sections[i] && sections[i].kind === 'outro'
&& acts[i] === 'resolution';
if (isRecap) arrangement = 0;
else if (i === hangAt) arrangement = 1;
rearrangeAt.push(arrangement);
}
}
// Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route,
// items, portal, watch cells. Pure data, seeded, serialisable; the facade
// builds it. Other archetypes get dungeon:null. Forked so existing draws
// don't move (same rule as venue:pop). The track summary tunes the
// dungeon's character (sealed/murky/big for dark slow songs).
let dungeonPlan = null;
if (picked.archetype === 'dungeon') {
const dRng = rng.fork('dungeon:plan');
dungeonPlan = {
map: genDungeon({ seed, rng: dRng, summary }),
};
}
return {
archetype: picked.archetype,
name: picked.name,
seed,
summary: { ...(summary || {}) },
dungeon: dungeonPlan,
shell,
projection,
population,
paletteMap,
perSection,
rearrangeAt,
venue: true,
readsDepth: false,
module: picked, // keep registry ref for trait/consumes checks
};
}
export function describeVenue(venue) {
if (!venue) return 'no venue';
return `${venue.name}(${venue.archetype}) pop ${venue.population.count} ${venue.population.kind} · proj ${venue.projection.kind}`;
}
+769
View File
@@ -0,0 +1,769 @@
// VENUE: Chamber — derelict room full of junk furniture.
//
// A sparse, still place. Window-light + floor reflections carry the viz
// tint; procedurally built junk (tables, chairs, cupboards, shelves with
// books, plants, box stacks) populates it, and the track's actors wander
// the room — visiting furniture, pausing to face it, meeting each other
// at the centre. Population density + light/fog evolve per Story act
// (setup sparse, climax bright, resolution dim); the arrangement salt
// re-hangs the room on story jumps. Primitives only — no glTF kit.
import { actorToGeometry } from '../actors/meshes.js';
import { buildSky, paintSky } from './sky.js';
// --- procedural junk furniture ------------------------------------------------
// Primitives only, palette-lit, seeded. Every builder returns { group, radius }
// with the group origin at floor level (+y up); radius is the footprint used
// by the repulsion passes. Deterministic in (rng): same draws, same junk.
function mulberry32(a) {
let s = a >>> 0;
return function () {
s |= 0; s = (s + 0x6D2B79F5) | 0;
let z = Math.imul(s ^ (s >>> 15), 1 | s);
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// Faint emissive in the piece's own colour: in the darkest rooms (darkest
// songs pick the chamber by design) lit-only shapes go black and the
// traverse moves the camera through nothing visible. Kept low for junk,
// higher for actors — the figures are the subject and must pop out of
// their own room even in a monochrome palette.
function junkMat(T, cc, rough = 0.7, emis = 0.14) {
return new T.MeshStandardMaterial({
color: new T.Color(cc[0], cc[1], cc[2]),
emissive: new T.Color(cc[0] * emis, cc[1] * emis, cc[2] * emis),
emissiveIntensity: 1.0,
roughness: rough,
metalness: 0.06,
});
}
function junkBox(T, parent, mat, w, h, d, x, y, z, ry = 0, rz = 0) {
const m = new T.Mesh(new T.BoxGeometry(w, h, d), mat);
m.position.set(x, y, z);
m.rotation.y = ry;
m.rotation.z = rz;
m.castShadow = false;
m.receiveShadow = false;
parent.add(m);
return m;
}
function buildTable(T, rng, pal) {
const g = new T.Group();
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.75);
const dark = junkMat(T, pal[(rng() * pal.length) | 0], 0.85);
junkBox(T, g, main, 1.5, 0.09, 0.95, 0, 0.72, 0);
for (const sx of [-0.65, 0.65]) for (const sz of [-0.38, 0.38]) {
junkBox(T, g, dark, 0.08, 0.72, 0.08, sx, 0.36, sz);
}
return { group: g, radius: 0.95 };
}
function buildChair(T, rng, pal) {
const g = new T.Group();
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.7);
junkBox(T, g, main, 0.52, 0.07, 0.5, 0, 0.45, 0);
junkBox(T, g, main, 0.52, 0.6, 0.07, 0, 0.78, -0.245);
for (const sx of [-0.21, 0.21]) for (const sz of [-0.2, 0.2]) {
junkBox(T, g, main, 0.06, 0.45, 0.06, sx, 0.225, sz);
}
return { group: g, radius: 0.5 };
}
function buildCupboard(T, rng, pal) {
const g = new T.Group();
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8);
const trim = junkMat(T, pal[(rng() * pal.length) | 0], 0.6);
junkBox(T, g, main, 1.15, 2.0, 0.55, 0, 1.0, 0);
junkBox(T, g, trim, 1.21, 0.08, 0.61, 0, 0.04, 0);
junkBox(T, g, trim, 1.21, 0.07, 0.61, 0, 2.03, 0);
for (const sx of [-0.27, 0.27]) {
junkBox(T, g, trim, 0.5, 1.7, 0.04, sx, 1.05, 0.28);
const knob = new T.Mesh(new T.SphereGeometry(0.035, 10, 8), trim);
knob.position.set(sx * 0.25, 1.05, 0.32);
g.add(knob);
}
return { group: g, radius: 0.75 };
}
function buildBooks(T, g, rng, pal, y, width) {
// One shelf-board of books: upright row, seeded widths/heights/colours,
// sometimes a leaner, sometimes a gap with a flat stack.
let x = -width / 2 + 0.08;
const end = width / 2 - 0.08;
while (x < end - 0.05) {
const r = rng();
if (r < 0.18 && x < end - 0.35) {
// Flat stack of 2-3.
const n = 2 + ((rng() * 2) | 0);
let sy = y;
for (let k = 0; k < n; k++) {
const bw = 0.3 + rng() * 0.12, bh = 0.055 + rng() * 0.02;
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85),
bw, bh, 0.24, x + bw / 2, sy + bh / 2, 0, (rng() - 0.5) * 0.2);
sy += bh;
}
x += 0.48;
} else {
const bw = 0.05 + rng() * 0.05, bh = 0.28 + rng() * 0.14;
const lean = (rng() < 0.12 && x < end - 0.2) ? -0.16 : 0;
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85),
bw, bh, 0.24, x + bw / 2, y + bh / 2, 0, 0, lean);
x += bw + 0.012 + (lean ? 0.06 : 0);
}
}
}
function buildShelf(T, rng, pal) {
const g = new T.Group();
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8);
junkBox(T, g, main, 0.06, 1.9, 0.42, -0.72, 0.95, 0);
junkBox(T, g, main, 0.06, 1.9, 0.42, 0.72, 0.95, 0);
junkBox(T, g, main, 1.5, 1.9, 0.03, 0, 0.95, -0.2);
const boards = [0.12, 0.72, 1.3, 1.87];
for (const y of boards) junkBox(T, g, main, 1.44, 0.06, 0.4, 0, y, 0);
for (const y of boards.slice(0, 3)) buildBooks(T, g, rng, pal, y + 0.03, 1.32);
return { group: g, radius: 0.85 };
}
function buildPlant(T, rng, pal) {
const g = new T.Group();
const cc = pal[(rng() * pal.length) | 0];
const pot = junkMat(T, [cc[0] * 0.6 + 0.08, cc[1] * 0.6 + 0.05, cc[2] * 0.6 + 0.04], 0.9);
// Foliage reads leafy in any palette: palette hue pulled toward green.
const leaf = junkMat(T,
[cc[0] * 0.35 + 0.1, cc[1] * 0.35 + 0.3, cc[2] * 0.35 + 0.1], 0.95);
const potMesh = new T.Mesh(new T.CylinderGeometry(0.24, 0.17, 0.42, 12), pot);
potMesh.position.y = 0.21;
g.add(potMesh);
const trunk = new T.Mesh(new T.CylinderGeometry(0.03, 0.045, 0.55, 8), pot);
trunk.position.y = 0.65;
g.add(trunk);
const blobs = 2 + ((rng() * 2) | 0);
for (let i = 0; i < blobs; i++) {
const r = 0.3 + rng() * 0.2;
const b = new T.Mesh(new T.IcosahedronGeometry(r, 0), leaf);
b.position.set((rng() - 0.5) * 0.5, 1.0 + i * 0.32 + rng() * 0.15, (rng() - 0.5) * 0.5);
b.rotation.set(rng() * 3, rng() * 3, rng() * 3);
g.add(b);
}
return { group: g, radius: 0.55 };
}
function buildBoxes(T, rng, pal) {
const g = new T.Group();
const n = 2 + ((rng() * 2) | 0);
let y = 0;
for (let i = 0; i < n; i++) {
const s = 0.55 + rng() * 0.3 - i * 0.06;
const h = 0.4 + rng() * 0.25;
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.9),
s, h, s * (0.8 + rng() * 0.4),
(rng() - 0.5) * 0.16, y + h / 2, (rng() - 0.5) * 0.16,
(rng() - 0.5) * 0.6);
y += h;
}
return { group: g, radius: 0.6 };
}
const JUNK_BUILDERS = [buildTable, buildChair, buildCupboard, buildShelf, buildPlant, buildBoxes];
export const chamber = {
name: 'Chamber',
archetype: 'chamber',
family: 'minimal',
kind: 'model',
venue: true,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'style'],
texture: 0.35,
// How much viz bloom this room can take before its highlights clip: the
// chamber is dim plaster and floor light, so it earns the blaze often.
reach: 1.1,
params: {
density: { type: 'float', range: [0.2, 0.9], default: 0.45, uniform: 'u_density', bias: 'density' },
jitter: { type: 'float', range: [0, 0.6], default: 0.25, uniform: 'u_jitter' },
light: { type: 'float', range: [0.3, 1.2], default: 0.7, uniform: 'u_light', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {},
// Venue RTT: the compositor calls this with the finished viz texture after
// compositing the viz stack into vizTarget. The window and the floor
// reflection sample it, so the viz appears INSIDE the room rather than
// composited over it. Texture object identity is stable frame-to-frame, so
// assigning map/emissiveMap is cheap; needsUpdate only on first bind.
bindVizTexture(instance, texture) {
if (!instance || !texture) return;
let dirty = false;
if (instance.windowMat && instance.windowMat.emissiveMap !== texture) {
instance.windowMat.emissiveMap = texture;
dirty = true;
}
// The spill shows the viz through its emissive channel (the room is too
// dark for diffuse alone); map rides along so the two never disagree.
if (instance.reflectMat && (instance.reflectMat.map !== texture
|| instance.reflectMat.emissiveMap !== texture)) {
instance.reflectMat.map = texture;
instance.reflectMat.emissiveMap = texture;
dirty = true;
}
if (dirty) {
if (instance.windowMat) instance.windowMat.needsUpdate = true;
if (instance.reflectMat) instance.reflectMat.needsUpdate = true;
}
},
// Base wash for the viz composite. The room's own wall colour is too dark
// to densify anything (dark song → dark palette → wash ≈ black, and the
// 5x section jump stays), so the wash is a glowing screen base instead:
// palette-tinted mid grey that arcs with venueState.light. A screen that
// glows brighter than its walls is physically sensible, and the wash
// carries the slow travel while calming the keyed shot's jumps.
// See Layer.washColor.
washColor(instance) {
const st = (instance && instance.venueState) || null;
const light = st ? st.light : 0.7;
let r = 0.05, g = 0.05, b = 0.07;
if (instance && instance.walls && instance.walls.length) {
const c = instance.walls[0].material.color;
r = c.r; g = c.g; b = c.b;
}
const glow = (0.07 + light * 0.09);
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
// Room shell — 5 planes: floor + 4 walls (no ceiling, open to sky)
const room = new T.Group();
room.name = 'room';
const floorMat = new T.MeshStandardMaterial({
color: new T.Color(0.14, 0.13, 0.12),
roughness: 0.85,
metalness: 0.04,
});
const wallMat = new T.MeshStandardMaterial({
color: new T.Color(0.18, 0.17, 0.16),
roughness: 0.92,
metalness: 0.02,
});
const floorGeo = new T.PlaneGeometry(22, 22);
const floor = new T.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.4;
floor.receiveShadow = false;
room.add(floor);
const wallGeo = new T.PlaneGeometry(22, 6);
const walls = [];
const wallPositions = [
{ pos: [0, 1.6, -11], rotY: 0 },
{ pos: [0, 1.6, 11], rotY: Math.PI },
{ pos: [-11, 1.6, 0], rotY: Math.PI / 2 },
{ pos: [11, 1.6, 0], rotY: -Math.PI / 2 },
];
for (const w of wallPositions) {
const wall = new T.Mesh(wallGeo, wallMat.clone());
wall.position.set(w.pos[0], w.pos[1], w.pos[2]);
wall.rotation.y = w.rotY;
wall.receiveShadow = false;
room.add(wall);
walls.push(wall);
}
// Window plane — the viz render surface. 16:9 like the viz that
// feeds it, so the image arrives undistorted; emissiveMap carries the
// viz (bound per-frame by bindVizTexture), emissive color stays the
// palette tint multiplier. Sized to fill ~40% of the frame from the
// room camera (was 6.4x3.6 ≈ 22% — a postage stamp the section's viz
// could not drive the frame through, flattening filmstrip direction).
const windowGeo = new T.PlaneGeometry(12, 6.75);
const windowMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.0,
roughness: 0.6,
metalness: 0.02,
});
const windowMesh = new T.Mesh(windowGeo, windowMat);
windowMesh.position.set(0, 1.35, -10.9);
windowMesh.name = 'window';
room.add(windowMesh);
// Floor spill of the viz screen. Same 16:9 texture, dimmed and
// translucent — reads as light spilling out of the window. It is a
// SECOND plane lying on the floor, so every way it differs from the
// floor reads as architecture: its rim reads as a raised bar, its face
// as a second floor. Four things keep it a light streak instead:
// exactly co-planar (no tilt — a 1° tilt over 10 units is a 22 cm ramp
// at this grazing angle), feathered on all sides, faded to nothing at
// the camera end (canvas bottom = near side) so no near rim ever slides
// under the lens, and pulled back toward the window.
let spillAlpha = null;
try {
if (typeof document !== 'undefined' && document.createElement) {
const cnv = document.createElement('canvas');
cnv.width = 256; cnv.height = 160;
const g = cnv.getContext('2d');
if (g) {
// Along-beam: opaque at the window end (canvas top = far
// side), gone at the camera end.
const along = g.createLinearGradient(0, 0, 0, 160);
along.addColorStop(0, '#fff');
along.addColorStop(0.45, '#888');
along.addColorStop(0.8, '#111');
along.addColorStop(1, '#000');
g.fillStyle = along;
g.fillRect(0, 0, 256, 160);
// Across-beam: kill the left/right rims.
g.globalCompositeOperation = 'destination-in';
const across = g.createLinearGradient(0, 0, 256, 0);
across.addColorStop(0, '#000');
across.addColorStop(0.18, '#fff');
across.addColorStop(0.82, '#fff');
across.addColorStop(1, '#000');
g.fillStyle = across;
g.fillRect(0, 0, 256, 160);
spillAlpha = new T.CanvasTexture(cnv);
}
}
} catch { spillAlpha = null; }
const reflectGeo = new T.PlaneGeometry(12, 6);
const reflectMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.03),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 0.35,
roughness: 0.35,
metalness: 0.15,
transparent: true,
opacity: 0.3,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
});
if (spillAlpha) reflectMat.alphaMap = spillAlpha;
const reflection = new T.Mesh(reflectGeo, reflectMat);
reflection.rotation.x = -Math.PI / 2;
reflection.position.set(0, -1.398, -4.6);
reflection.name = 'reflection';
room.add(reflection);
scene.add(room);
// Lighting — ambient + key from the camera side. The key used to sit
// behind the window wall, leaving every camera-facing surface in
// shadow lit only by ambient — the room read as a black void with a
// floating screen. Front light keeps a dim room present.
const ambient = new T.AmbientLight(0xffffff, 0.42);
scene.add(ambient);
const sun = new T.DirectionalLight(0xfff6e0, 0.95);
sun.position.set(2.5, 4.2, 7.5);
sun.castShadow = false;
scene.add(sun);
const fill = new T.DirectionalLight(0xffffff, 0.22);
fill.position.set(-3.2, 2.0, 4.0);
scene.add(fill);
scene.background = null;
scene.fog = new T.Fog(0x0e0e12, 12, 28);
scene.userData.transparentBackground = true;
// Song sky above the open room — replaces clear-to-black. Radius 90
// clears the 22-unit room with margin; fog melts the horizon.
const sky = buildSky(T, { radius: 90 });
scene.add(sky.mesh);
// Population container — junk furniture
const popGroup = new T.Group();
popGroup.name = 'furniture';
scene.add(popGroup);
return {
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
reflection, reflectMat,
ambient, sun, fill,
skyMesh: sky.mesh, skyMat: sky.mat,
popGroup,
seed,
venueSpec: venueSpec || null,
builtKey: null,
popCount: 0,
};
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
const T = THREE;
const t = timeline.time;
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
const identity = personality ? personality.identity : null;
const shape = personality ? personality.shape : null;
const space = personality ? personality.space : null;
const style = personality ? personality.style : null;
// Trait reads so lint / scene-gate sees delta
const shapeTilt = shape ? shape.tilt : 0;
const shapeSides = shape ? shape.sides : 0;
const spaceHorizon = space ? space.horizon : 0.5;
const styleWeight = style ? style.lineWeight : 0.5;
const ink = identity ? identity.ink : null;
const lattice = identity ? identity.lattice : null;
const elementScale = identity ? identity.lattice.elementScale : 0.35;
void elementScale; void lattice; void shapeSides; void shapeTilt; void styleWeight;
// Resolve venueSpec — prefer passed-in, fallback to instance stored
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
const perSectionMul = vs && vs.perSection ? 1 : 1;
if (vState) instance.venueState = vState;
// Ground/personality driven materials. The room MUST read the story arc:
// a static dark box dilutes every section change into churn (filmstrip
// direction collapsed when the venue arrived). venueState.light swings
// ~0.5 (setup) to ~0.92 (climax) — map it onto wall/floor albedo and
// the sun so the room breathes with the song, not just the window.
const c0 = pal[0];
// The venue is the STABLE place — it moves less than the viz, not
// more. Full light steps at every section made consecutive sections
// ~4x further apart than without a venue (a shuffle, not an arc), so
// the room takes half steps; the viz window takes the full ones.
//
// Walls take their HUE from the palette but their LEVEL from the room:
// a dark song gives a near-black palette, and palette-dark walls are
// unlightable — the room read as a black void with a floating screen.
// Normalise the tint so the song colours the room without darkening it.
const roomLight = vState ? vState.light : 0.7;
const tintMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
const tint = [c0[0] / tintMax, c0[1] / tintMax, c0[2] / tintMax];
// Bright songs light their own room through the palette; dark songs
// cannot (their tint is near-black even normalised against the level
// the room needs). So the room's level compensates the palette's:
// dark palette → higher level, bright palette → lower. Keeps the room
// present for drone without flooding it for centre.
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
const wallLevel = (0.22 + roomLight * 0.30) * comp;
const floorLevel = (0.18 + roomLight * 0.26) * comp;
instance.floorMat.color.setRGB(tint[0] * floorLevel, tint[1] * floorLevel, tint[2] * floorLevel);
instance.floorMat.roughness = 0.85;
for (const w of instance.walls) {
w.material.color.setRGB(tint[0] * wallLevel, tint[1] * wallLevel, tint[2] * wallLevel);
}
if (instance.sun) instance.sun.intensity = 0.5 + roomLight * 1.0;
if (instance.ambient) instance.ambient.intensity = 0.32 + roomLight * 0.5;
// Window brightness from story light. The IMAGE is the viz texture
// (emissiveMap); emissive color stays near-white so the viz keeps its
// own colors. Palette tint reaches the room via floor/walls instead.
{
const lightMul = vState ? vState.light : 1;
instance.windowMat.emissive.setRGB(1, 1, 1);
instance.windowMat.emissiveIntensity = (params.light || 0.7) * (0.75 + lightMul * 0.5);
if (instance.reflectMat) {
// Tint the spill toward the palette floor colour so it melts into
// the floor instead of reading as a lit slab with its own edge.
instance.reflectMat.emissive.setRGB(c0[0] * 0.5, c0[1] * 0.48, c0[2] * 0.55);
instance.reflectMat.color.setRGB(c0[0] * 0.10, c0[1] * 0.10, c0[2] * 0.12);
instance.reflectMat.emissiveIntensity = 0.5 * (params.light || 0.7) * (0.75 + lightMul * 0.5);
}
}
// Song sky + matched fog. The sky replaces clear-to-black; fog takes
// the dome's mid colour so the horizon melts instead of banding.
paintSky(instance.skyMat, {
palette: pal, light: roomLight, style,
seed: instance.seed,
});
// Fog via space + story: resolution (fog 0.55) closes the room in,
// climax (0.18) opens it. Swing near AND far so the depth change reads.
if (scene.fog) {
const fogMul = vState ? vState.fog : 0.35;
const fogDepth = (space ? 12 + space.depth * 6 : 15) * (0.7 + fogMul * 0.75);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 10 + fogMul * 10;
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
if (mid) scene.fog.color.copy(mid);
else scene.fog.color.setRGB(c0[0] * 0.06, c0[1] * 0.06, c0[2] * 0.08);
}
// Rebuild population if needed (deterministic, seeded). The arrangement
// salt is part of the key: same salt → same layout, guaranteed
// carryover; a new salt after a story jump re-hangs the room (always
// under a camera cut — see ArcDriver.venueJumpAt). Nothing else here
// may rebuild mid-song: no per-section seeds in this key.
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
instance.venueArrangement = arrangement;
const protagonistSides = identity && identity.cast ? identity.cast.protagonist.sides : 0;
const actorSeed = actorSpec ? actorSpec.seed || 0 : 0;
const popKey = vs ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:p${protagonistSides}:s${actorSeed}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`;
const needRebuild = instance.builtKey !== popKey || !instance.popCount;
if (needRebuild) {
for (const child of [...instance.popGroup.children]) {
instance.popGroup.remove(child);
child.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
}
});
}
const count = vs && vs.population ? Math.max(2, Math.min(12, vs.population.count | 0)) : 5;
const spread = vs && vs.population ? vs.population.spread || 0.7 : 0.7;
const jitter = vs && vs.population ? vs.population.jitter || 0.25 : 0.25;
// Headcount split: 1-3 actors (the track's figures, always
// visible — they take the first indices so popMul gating never
// hides them while junk is still showing), the rest junk.
const actorCount = Math.min(3, Math.max(1, Math.round(count / 3)));
const junkCount = Math.max(1, count - actorCount);
// Junk dealt from a shuffled deck: the first slots always land
// distinct types (table, chair, cupboard, shelf, plant, boxes),
// then the deck cycles. Seeded by (seed, arrangement), so a
// re-hang deals different junk in different places — still
// deterministic per key, stable across seeks.
const rng = mulberry32(((instance.seed >>> 0)
^ Math.imul(arrangement + 1, 0x9E3779B9)) >>> 0);
const deck = JUNK_BUILDERS.map((_, i) => i);
for (let i = deck.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
const tmp = deck[i]; deck[i] = deck[j]; deck[j] = tmp;
}
// Build pieces first — each reports its footprint radius, which
// the placement passes below need before positioning. Actors own
// indices 0..actorCount-1, so junk starts after them and the
// popMul sparse-setup gating keeps the figures while thinning
// the junk.
const pieces = [];
for (let j = 0; j < junkCount; j++) {
const built = JUNK_BUILDERS[deck[j % deck.length]](T, rng, pal);
built.group.userData.index = actorCount + j;
pieces.push(built);
}
// Deterministic positions — golden-angle spiral + separation passes.
// The spiral gives even coverage with an open middle for the
// camera; the relaxation below enforces per-pair gaps from the
// pieces' own footprint radii, so a cupboard never swallows a
// chair. Arrangement salt rotates the whole spiral, so a re-hang
// is a visibly new layout — deterministic per (seed,
// arrangement), stable across seeks.
const seeds = [];
for (let i = 0; i < junkCount; i++) {
const frac = (i + 0.5) / Math.max(1, junkCount);
const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3;
const rad = (2.6 + 5.8 * Math.sqrt(frac)) * (0.7 + spread * 0.45);
let rx = Math.cos(ang) * rad + Math.sin(i * 78.233) * jitter * 1.2;
let rz = Math.sin(ang) * rad * 0.78 - 1.2 + Math.cos(i * 43.12) * jitter;
rx = Math.max(-9, Math.min(9, rx));
// Never in front of the room: the camera pushes as close as z≈0.4,
// and at furniture scale (1+ units wide) anything nearer than
// z≈-1.5 is at the lens and reads as a bar across the viz.
// Furniture lives in the back two-thirds, toward the window.
rz = Math.max(-8.5, Math.min(-1.5, rz));
seeds.push([rx, rz]);
}
// Repulsion passes: push overlapping pairs apart. Minimum gap from
// the pair's footprints — big pieces need more room. Deterministic,
// analytic, no wall-clock — same inputs always give same layout.
for (let pass = 0; pass < 12; pass++) {
for (let a = 0; a < junkCount; a++) {
for (let b = a + 1; b < junkCount; b++) {
const minDist = (pieces[a].radius + pieces[b].radius) * 0.7 + 0.6;
const dx = seeds[b][0] - seeds[a][0];
const dz = seeds[b][1] - seeds[a][1];
const d = Math.hypot(dx, dz);
if (d >= minDist || d < 1e-4) continue;
const push = (minDist - d) * 0.5;
const nx = dx / d, nz = dz / d;
seeds[a][0] = Math.max(-9, Math.min(9, seeds[a][0] - nx * push));
seeds[a][1] = Math.max(-8.5, Math.min(-1.5, seeds[a][1] - nz * push));
seeds[b][0] = Math.max(-9, Math.min(9, seeds[b][0] + nx * push));
seeds[b][1] = Math.max(-8.5, Math.min(-1.5, seeds[b][1] + nz * push));
}
}
}
for (let i = 0; i < junkCount; i++) {
const g = pieces[i].group;
g.position.set(seeds[i][0], -1.4, seeds[i][1]);
g.rotation.y = rng() * Math.PI * 2;
g.userData.baseY = -1.4;
g.userData.kind = 'junk';
instance.popGroup.add(g);
}
// Actors — the track's figures, built from the same monolith the
// viz hero uses (shared part geometries, own palette material per
// figure). Each gets two furniture anchors to visit plus a centre
// meeting point; per-frame motion walks that loop analytically
// below. Feet planted via bounding box; yaw faces whoever or
// whatever the figure is with.
let partGeos = null;
try {
if (identity && actorSpec && actorSpec.form) {
const body = actorToGeometry(actorSpec, identity, T);
partGeos = body.children
.filter((c) => c.geometry)
.map((c) => ({ geo: c.geometry, partIndex: c.userData.partIndex }));
}
} catch { partGeos = null; }
const clampRoom = (x, z) => [
Math.max(-9, Math.min(9, x)),
Math.max(-8.5, Math.min(-1.5, z)),
];
const actorCfg = [];
const actorColors = [];
for (let a = 0; a < actorCount; a++) {
const ag = new T.Group();
// Distinct palette entries per figure so the cast reads as
// individuals, not clones, even in narrow palettes.
let ci = (rng() * pal.length) | 0;
for (let guard = 0; guard < 4 && actorColors.includes(ci); guard++) {
ci = (rng() * pal.length) | 0;
}
actorColors.push(ci);
const acc = junkMat(T, pal[ci % pal.length], 0.5, 0.3);
if (partGeos && partGeos.length) {
for (const p of partGeos) {
const m = new T.Mesh(p.geo, acc);
m.userData.partIndex = p.partIndex;
m.castShadow = false;
m.receiveShadow = false;
ag.add(m);
}
} else {
// No actor to unwrap (hand-built looks, tests): a plain
// figure from primitives so the room still has its cast.
junkBox(T, ag, acc, 0.5, 0.9, 0.3, 0, 0.75, 0);
const head = new T.Mesh(new T.SphereGeometry(0.18, 12, 10), acc);
head.position.y = 1.38;
ag.add(head);
}
const s = 0.32 + rng() * 0.13;
ag.scale.setScalar(s);
ag.updateMatrixWorld(true);
const bbox = new T.Box3().setFromObject(ag);
const yBase = Number.isFinite(bbox.min.y) ? -1.4 - bbox.min.y : -1.4;
// Two distinct furniture anchors to visit (or the same twice
// when the room holds a single piece).
const kA = (rng() * junkCount) | 0;
let kB = (rng() * junkCount) | 0;
if (junkCount > 1) {
let guard = 0;
while (kB === kA && guard++ < 8) kB = (rng() * junkCount) | 0;
}
const standBeside = (k) => {
const ang = rng() * Math.PI * 2;
const dist = pieces[k].radius + 0.55;
const [sx, sz] = clampRoom(
seeds[k][0] + Math.cos(ang) * dist,
seeds[k][1] + Math.sin(ang) * dist);
return { stand: [sx, sz], anchor: [seeds[k][0], seeds[k][1]] };
};
const vA = standBeside(kA), vB = standBeside(kB);
const cx = (rng() - 0.5) * 4;
const cz = Math.max(-8.5, Math.min(-1.5, -3.5 + (rng() - 0.5) * 1.5));
ag.position.set(vA.stand[0], yBase, vA.stand[1]);
ag.userData.index = a;
ag.userData.kind = 'actor';
ag.userData.baseY = yBase;
instance.popGroup.add(ag);
actorCfg.push({
group: ag, yBase,
period: 40 + rng() * 25,
phase: rng() * 40,
standA: vA.stand, anchorA: vA.anchor,
standB: vB.stand, anchorB: vB.anchor,
center: [cx, cz],
partner: (a + 1) % actorCount,
});
}
instance.actorCfg = actorCfg;
instance.builtKey = popKey;
instance.popCount = count;
instance.needsCompile = true;
}
// Actors walk a closed loop — stand at furniture A facing it, meet at
// the centre facing their partner, stand at furniture B facing it,
// back to centre — on a slow seeded period with unsynced phases.
// Smootherstep legs start and end at rest, so figures pause, look,
// then move: visiting, not patrolling. Facing blends toward the next
// leg's target, so turns are smooth. Everything is f(t): positions
// first for all figures, then yaws (a figure faces its partner's
// same-frame position). Junk stands still — it is furniture.
// Nothing here integrates: a seek lands on the same room as playback.
const actors = instance.actorCfg || [];
const smoother = (u) => { const c = Math.max(0, Math.min(1, u)); return c * c * c * (c * (c * 6 - 15) + 10); };
const actorPos = actors.map((cfg) => {
const u = ((((t + cfg.phase) % cfg.period) + cfg.period) % cfg.period) / cfg.period;
const leg = Math.min(3, Math.floor(u * 4));
const e = smoother(u * 4 - leg);
const W = [cfg.standA, cfg.center, cfg.standB, cfg.center];
const p0 = W[leg], p1 = W[(leg + 1) % 4];
return [p0[0] + (p1[0] - p0[0]) * e, p0[1] + (p1[1] - p0[1]) * e, e, leg];
});
actors.forEach((cfg, i) => {
const g = cfg.group;
const px = actorPos[i][0], pz = actorPos[i][1];
const e = actorPos[i][2], leg = actorPos[i][3];
g.position.set(px, cfg.yBase + 0.02 + Math.sin(t * 0.9 + cfg.phase) * 0.03, pz);
// Face targets per waypoint: the visited furniture, or the
// partner's live position at the centre (a lone figure watches
// the window instead). Blend across the leg boundary for turns.
const solo = actors.length < 2;
const partnerPos = solo ? [0, -10] : actorPos[cfg.partner].slice(0, 2);
const F = [cfg.anchorA, partnerPos, cfg.anchorB, partnerPos];
const f0 = F[leg], f1 = F[(leg + 1) % 4];
const fx = f0[0] + (f1[0] - f0[0]) * e, fz = f0[1] + (f1[1] - f0[1]) * e;
if (Math.hypot(fx - px, fz - pz) > 1e-3) g.rotation.y = Math.atan2(fx - px, fz - pz);
});
for (const g of instance.popGroup.children) {
const idx = g.userData.index || 0;
const isActor = g.userData.kind === 'actor';
// Actors are the subject: always dressed. Junk thins out in
// sparse setups via popMul — as a SMOOTH opacity fade, never a
// visible toggle: the interpolated venueState crosses thresholds
// at arbitrary mid-section moments, and hard cuts there read as
// random flashes (same pattern as the ride's crew). Actors take
// the first indices so the fade never touches them.
const popMul = vState ? vState.popMul : 1;
const showIndex = idx / Math.max(1, instance.popCount);
const fade = isActor ? 1
: Math.max(0, Math.min(1, ((0.35 + popMul * 0.75) - showIndex) / 0.12));
g.visible = opacity > 0.01 && fade > 0.01;
g.traverse((mesh) => {
if (mesh.isMesh) {
mesh.material.opacity = opacity * fade;
mesh.material.transparent = opacity * fade < 0.999;
// The room's population breathes with the story arc: dim in
// setup, bright at the climax. Pure f(venueState), no clock.
// Figures breathe brighter — they must read against their
// own room even when the palette runs monochrome.
if (vState && mesh.material.emissiveIntensity !== undefined) {
mesh.material.emissiveIntensity = isActor
? 0.9 + vState.light * 1.4
: 0.5 + vState.light * 1.0;
}
}
});
}
// Subtle room drift from camera trait
const camTrait = personality ? personality.camera : null;
const camSway = camTrait ? camTrait.sway : 0;
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
instance.room.position.x = Math.sin(t * camSwayRate) * camSway * 0.35;
instance.room.position.z = Math.cos(t * camSwayRate * 0.83) * camSway * 0.22;
void perSectionMul; void spaceHorizon; void instance; void params; void T;
},
};
export default chamber;
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
// Hazards submodule — gates and set-pieces the walk appears to negotiate.
//
// Visual only: the facade adds avoidanceOffset() to the hero's lateral x.
// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng
// only, all motion analytic f(t) so seek === playback and preview === export.
// Type is a discrete plan-time choice; footprints stay narrow so a hazard never
// swallows the path. The world is STATIC (the camera walks), so hazard marks
// sit at fixed arclength and the hero is compared against them directly.
//
// Contract (exact export names/signatures):
// HAZARD_BUILDERS: registry object { gate, gap, patrol }
// planHazards({ seed, rng, sections, span }) -> per-section [{s, type}]
// buildHazards({ THREE, sectionPlan, seed }) -> { group, items }
// updateHazards({ hazards, sectionPlan, t, light, opacity, pathPlan }) -> void
// avoidanceOffset(heroS, sectionPlan, t) -> lateral nudge, |x| <= 1.0
import { smooth01 } from './shared.js';
const FLOOR_Y = -1.15;
function std(THREE, color, opts = {}) {
return new THREE.MeshStandardMaterial({
color: new THREE.Color(color[0], color[1], color[2]),
roughness: opts.roughness ?? 0.6,
metalness: opts.metalness ?? 0.1,
emissive: new THREE.Color(
(opts.emissive ?? [0, 0, 0])[0],
(opts.emissive ?? [0, 0, 0])[1],
(opts.emissive ?? [0, 0, 0])[2]
),
emissiveIntensity: opts.emissiveIntensity ?? 1,
});
}
function stone(THREE, tint) {
return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 });
}
function gate(THREE, rng, tint) {
// Portcullis: two jambs + lintel + hanging bars. The walk passes through
// the middle — the hero sidesteps via avoidanceOffset, never through iron.
const g = new THREE.Group();
const mat = stone(THREE, tint);
const iron = std(THREE, [0.1, 0.1, 0.12], { roughness: 0.45, metalness: 0.7 });
for (const side of [-1, 1]) {
const jamb = new THREE.Mesh(new THREE.BoxGeometry(0.36, 3.4, 0.5), mat);
jamb.position.set(side * 1.85, FLOOR_Y + 1.7, 0);
g.add(jamb);
}
const lintel = new THREE.Mesh(new THREE.BoxGeometry(4.1, 0.5, 0.5), mat);
lintel.position.set(0, FLOOR_Y + 3.6, 0);
g.add(lintel);
const n = 5;
const bars = new THREE.Group();
for (let k = 0; k < n; k++) {
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.6, 6), iron);
bar.position.set(-1.2 + k * (2.4 / (n - 1)), FLOOR_Y + 2.1, 0);
bars.add(bar);
}
g.add(bars);
// Lifted per frame by updateHazards: the gate opens as the hero nears
// and falls behind them — the walk passes through iron, never through
// bars. Analytic f(heroS), so a seek lands on the same opening.
g.userData.liftBars = bars;
return g;
}
function gap(THREE, rng, tint) {
// Broken floor: tilted flagstone shards in the FLOOR's own colours with
// one dim stone crack breathing between them — damage in place, not a
// black square. Pure visual — the hero steps around it via
// avoidanceOffset. The facade re-tints all shard/crack materials per
// frame from the floor (see the gapMats registry below) so the break
// always matches its surroundings; the tints passed here are only the
// build-time seed visible in headless checks.
const g = new THREE.Group();
const shardMat = stone(THREE, tint);
for (let k = 0; k < 5; k++) {
const w = 0.4 + rng.next() * 0.35;
const shard = new THREE.Mesh(new THREE.BoxGeometry(w, 0.07, 0.3 + rng.next() * 0.25), shardMat);
const a = (k / 5) * Math.PI * 2 + rng.next() * 0.5;
shard.position.set(Math.cos(a) * (0.55 + rng.next() * 0.25), FLOOR_Y + 0.045, Math.sin(a) * (0.65 + rng.next() * 0.3));
shard.rotation.y = -a + (rng.next() - 0.5) * 0.6;
shard.rotation.z = (rng.next() - 0.5) * 0.35;
g.add(shard);
}
const edgeMat = std(THREE, tint, { roughness: 0.5, emissive: tint, emissiveIntensity: 0.8 });
const crack = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.03, 1.1), edgeMat);
crack.position.set((rng.next() - 0.5) * 0.3, FLOOR_Y + 0.03, 0);
crack.rotation.y = (rng.next() - 0.5) * 0.4;
g.add(crack);
// Both materials re-tinted per frame — the facade owns the floor colours.
g.userData.edgeMat = edgeMat;
g.userData.gapMats = { shardMat, crackMat: edgeMat };
return g;
}
function patrol(THREE, rng, tint) {
// Watch lantern: post + swinging glow cage. The sway is analytic f(t).
const g = new THREE.Group();
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.6, 6), iron);
post.position.y = FLOOR_Y + 1.3;
g.add(post);
const cageMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.6 });
const cage = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), cageMat);
cage.position.y = FLOOR_Y + 2.7;
g.add(cage);
const arm = new THREE.Group();
arm.position.y = FLOOR_Y + 2.7;
const lampMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.2 });
const lamp = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.7, 10, 1, true), lampMat);
lamp.position.y = -0.5;
arm.add(lamp);
g.add(arm);
g.userData.swing = { arm, phase: rng.next() * Math.PI * 2, cageMat, base: 1.6 };
return g;
}
export const HAZARD_BUILDERS = { gate, gap, patrol };
export const HAZARD_TINTS = {
gate: [0.45, 0.45, 0.48],
gap: [1.0, 0.4, 0.1],
patrol: [0.95, 0.75, 0.4],
};
/**
* Per-frame life for map-seated hazards. Positions are fixed (the map seats
* everything once at build); only the portcullis lift, lantern swing and
* ember glow animate, all analytic f(t, heroS) so seeks agree with playback.
*
* Each item carries userData.home = { x, z, yaw } (set by the facade at
* build) and gates additionally carry userData.hazardS (route arclength).
* partyS lists the route positions of the whole procession (hero + trailing
* companions, lead members in first-person) so the portcullis stays up until
* the last walker clears it — never hero-only.
*/
export function updateHazards({ hazards = null, t = 0, light = 1, opacity = 1, heroS = null, partyS = null } = {}) {
const items = hazards?.items ?? (Array.isArray(hazards) ? hazards : []);
for (const item of items) {
item.visible = opacity > 0.001;
if (!item.visible) continue;
const home = item.userData.home;
if (home) {
item.position.set(home.x, 0, home.z);
item.rotation.y = home.yaw || 0;
}
// Portcullis lift: shut at distance, fully raised while ANY walker
// of the procession is under it. The hero leads in first-person and
// trails in planted shots, so the lift keyed on the hero alone drops
// on the trailers (or shuts on the leader). Opens over ~4 units of
// approach on the near side and holds until the last member clears
// the far side + ~2 margin — no bar ever fills the near plane and no
// walker passes through iron.
const lift = item.userData.liftBars;
if (lift && typeof item.userData.hazardS === 'number') {
const members = Array.isArray(partyS) && partyS.length
? partyS
: (typeof heroS === 'number' ? [heroS] : []);
let liftK = 0;
for (const ms of members) {
if (typeof ms !== 'number') continue;
const d = Math.abs(ms - item.userData.hazardS);
liftK = Math.max(liftK, smooth01((4.5 - d) / 3.7));
}
lift.position.y = 2.4 * liftK;
}
const sw = item.userData.swing;
if (sw) {
sw.arm.rotation.z = Math.sin(t * 0.8 + sw.phase) * 0.35;
sw.cageMat.emissiveIntensity = sw.base * (0.4 + 0.6 * light)
* (0.85 + 0.15 * Math.sin(t * 3.1 + sw.phase));
}
const em = item.userData.edgeMat;
if (em) em.emissiveIntensity = 0.8 * (0.4 + 0.6 * light);
}
}
/**
* Lateral hero nudge so the walk appears to negotiate near hazards.
* Pure f(heroS, sectionPlan, t): hazards sit at fixed s, the hero is compared
* against them directly. Each nearby hazard pushes the hero away with a
* gaussian falloff (sigma 3, amp 0.7); gates push to alternating sides by
* index (through the bars' gaps, never through iron). Clamped to |x| <= 1.0
* so the hero holds the corridor.
*/
export function avoidanceOffset(heroS = 0, sectionPlan = null, t = 0) {
let list = sectionPlan ?? [];
if (Array.isArray(list) && list.length && Array.isArray(list[0])) list = list.flat();
if (!Array.isArray(list)) return 0;
let sum = 0;
for (let i = 0; i < list.length; i++) {
const e = list[i] ?? {};
const raw = (heroS - (e.s ?? 0)) / 3;
const g = Math.exp(-(raw * raw));
if (g < 0.001) continue;
const dir = (i % 2 === 0 ? 1 : -1);
sum += 0.7 * g * dir;
}
void t;
return Math.max(-1.0, Math.min(1.0, sum));
}
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
// Props submodule — library of dungeon dressing + the props the walk meets.
//
// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng
// only, all motion analytic f(t) so seek === playback and preview === export.
// Type/side are discrete plan-time choices; footprints stay small so a prop
// never swallows the path. The world is STATIC (the camera walks, unlike the
// ride where the world streams), so props are seated once per section plan and
// only their glow animates per frame.
//
// Contract (exact export names/signatures):
// PROP_BUILDERS: registry object { rubble, furniture, trees, rampart, torch, banner, stalagmite }
// planProps({ seed, rng, sections, span }) -> per-section [{s, side, type}]
// planPortal({ seed, rng, span }) -> { s, side }
// buildProps({ THREE, sectionPlan, palette, seed }) -> { group, items }
// updateProps({ props, sectionPlan, t, light, popMul, opacity, palette, pathPlan }) -> void
// buildPortal({ THREE, palette }) -> { group, discMat }
// PORTAL_RING_R: portal ring radius (facade frames the shot around it)
const FLOOR_Y = -1.15;
export const PORTAL_RING_R = 1.3;
function wrapS(s, span) {
return (((s % span) + span) % span);
}
function std(THREE, color, opts = {}) {
return new THREE.MeshStandardMaterial({
color: new THREE.Color(color[0], color[1], color[2]),
roughness: opts.roughness ?? 0.7,
metalness: opts.metalness ?? 0.08,
emissive: new THREE.Color(
(opts.emissive ?? [0, 0, 0])[0],
(opts.emissive ?? [0, 0, 0])[1],
(opts.emissive ?? [0, 0, 0])[2]
),
emissiveIntensity: opts.emissiveIntensity ?? 1,
});
}
function stoneMat(THREE, tint) {
return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 });
}
function box(THREE, g, w, h, d, material, x, y, z, ry = 0) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
m.position.set(x, y, z);
if (ry) m.rotation.y = ry;
g.add(m);
return m;
}
function rubble(THREE, rng, tint) {
const g = new THREE.Group();
const mat = stoneMat(THREE, tint);
const n = 2 + Math.floor(rng.next() * 3);
for (let k = 0; k < n; k++) {
const r = 0.25 + rng.next() * 0.45;
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), mat);
rock.position.set((rng.next() - 0.5) * 1.6, FLOOR_Y + r * 0.4, (rng.next() - 0.5) * 1.6);
rock.scale.y = 0.7;
rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3);
g.add(rock);
}
return g;
}
function furniture(THREE, rng, tint) {
// A broken table: top slab + legs, one leg short so it tilts.
const g = new THREE.Group();
const mat = stoneMat(THREE, tint);
const top = box(THREE, g, 1.4, 0.09, 0.9, mat, 0, FLOOR_Y + 0.72, 0);
top.rotation.z = 0.06;
for (const [sx, sz, h] of [[-0.6, -0.35, 0.72], [0.6, -0.35, 0.72], [-0.6, 0.35, 0.72], [0.6, 0.35, 0.5]]) {
box(THREE, g, 0.08, h, 0.08, mat, sx, FLOOR_Y + h / 2, sz);
}
return g;
}
function trees(THREE, rng, tint) {
const g = new THREE.Group();
const bark = stoneMat(THREE, tint);
const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 });
const n = 2 + Math.floor(rng.next() * 2);
for (let k = 0; k < n; k++) {
const h = 1.6 + rng.next() * 1.6;
const x = (rng.next() - 0.5) * 3, z = (rng.next() - 0.5) * 3;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 0.9, 5), bark);
trunk.position.set(x, FLOOR_Y + 0.45, z);
const crown = new THREE.Mesh(new THREE.ConeGeometry(0.65 + rng.next() * 0.4, h, 7), leaf);
crown.position.set(x, FLOOR_Y + 0.9 + h / 2, z);
crown.userData.swayPhase = rng.next() * Math.PI * 2;
g.add(trunk); g.add(crown);
}
return g;
}
function rampart(THREE, rng, tint) {
// Crenellated parapet block: wall + merlons, long axis with the path.
const g = new THREE.Group();
const mat = stoneMat(THREE, tint);
box(THREE, g, 0.5, 1.0, 2.6, mat, 0, FLOOR_Y + 0.5, 0);
const n = 3 + Math.floor(rng.next() * 2);
for (let k = 0; k < n; k++) {
box(THREE, g, 0.5, 0.4, 0.34, mat, 0, FLOOR_Y + 1.2, -1.1 + k * (2.2 / Math.max(1, n - 1)));
}
return g;
}
function torchProp(THREE, rng, tint) {
// Wall bracket + flame: the flame material flickers in update.
const g = new THREE.Group();
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.5, 6), iron);
arm.rotation.x = 0.7;
arm.position.set(0, FLOOR_Y + 1.7, 0);
g.add(arm);
const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.06, 0.14, 8), iron);
cup.position.set(0, FLOOR_Y + 1.88, 0.17);
g.add(cup);
const flameMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 2.2 });
const flame = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), flameMat);
flame.position.set(0, FLOOR_Y + 2.02, 0.17);
flame.scale.y = 1.5;
g.add(flame);
g.userData.flame = { mat: flameMat, mesh: flame, phase: rng.next() * Math.PI * 2, base: 2.2 };
return g;
}
function banner(THREE, rng, tint) {
const g = new THREE.Group();
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.6, 6), iron);
pole.position.set(0, FLOOR_Y + 1.3, 0);
g.add(pole);
const clothMat = std(THREE, tint, { roughness: 0.85, emissive: tint, emissiveIntensity: 0.35 });
const cloth = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 1.4), clothMat);
cloth.position.set(0.36, FLOOR_Y + 1.7, 0);
g.add(cloth);
g.userData.cloth = { mesh: cloth, phase: rng.next() * Math.PI * 2 };
return g;
}
function stalagmite(THREE, rng, tint) {
const g = new THREE.Group();
const mat = stoneMat(THREE, tint);
const h = 0.8 + rng.next() * 1.4;
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.3 + rng.next() * 0.2, h, 7), mat);
cone.position.set(0, FLOOR_Y + h / 2, 0);
g.add(cone);
if (rng.next() < 0.5) {
// Stalactite above: the cave bites both ways.
const ch = 0.5 + rng.next() * 0.8;
const up = new THREE.Mesh(new THREE.ConeGeometry(0.25, ch, 7), mat);
up.rotation.x = Math.PI;
up.position.set((rng.next() - 0.5) * 0.6, FLOOR_Y + 3.4 - ch / 2, 0);
g.add(up);
}
return g;
}
function bush(THREE, rng, tint) {
// One-mesh boundary planting: a squashed leafy blob, faintly emissive
// so the green edge reads at night. Planted densely along outdoor
// boundaries, so cheap matters more than pretty.
const g = new THREE.Group();
const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 });
const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), leaf);
blob.scale.set(1 + rng.next() * 0.4, 0.65 + rng.next() * 0.2, 1 + rng.next() * 0.4);
blob.position.y = FLOOR_Y + 0.3;
blob.rotation.y = rng.next() * Math.PI;
g.add(blob);
return g;
}
function boulder(THREE, rng, tint) {
// One-mesh rocky barrier: a squashed gray mass for castle-wall edges.
// Faint emissive like the junk stone so dark songs still read it.
const g = new THREE.Group();
const mat = stoneMat(THREE, tint);
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), mat);
rock.scale.set(1 + rng.next() * 0.4, 0.7 + rng.next() * 0.25, 1 + rng.next() * 0.4);
rock.position.y = FLOOR_Y + 0.42;
rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3);
g.add(rock);
return g;
}
export const PROP_BUILDERS = { rubble, furniture, trees, rampart, torch: torchProp, banner, stalagmite, bush, boulder };
export const PROP_TINTS = {
rubble: [0.45, 0.44, 0.46],
furniture: [0.4, 0.33, 0.26],
trees: [0.16, 0.32, 0.16],
rampart: [0.48, 0.47, 0.5],
torch: [1.0, 0.55, 0.18],
banner: [0.6, 0.14, 0.16],
stalagmite: [0.5, 0.5, 0.54],
bush: [0.14, 0.3, 0.14],
boulder: [0.45, 0.44, 0.46],
};
/**
* Analytic glow for placed dressing: flames breathe, cloth sways. Positions
* are fixed (the map seats everything once); only light animates, as pure
* f(t). Works on any group carrying userData.flame / userData.cloth, i.e.
* torch and banner builder output.
*/
export function animateDressing(item, t, light = 1) {
const fl = item.userData.flame;
if (fl && fl.mat) {
fl.mat.emissiveIntensity = fl.base * (0.35 + 0.65 * light)
* (0.85 + 0.15 * Math.sin(t * 9.3 + fl.phase));
if (fl.mesh) fl.mesh.scale.y = 1.5 + Math.sin(t * 7.1 + fl.phase) * 0.12;
}
const cl = item.userData.cloth;
if (cl && cl.mesh) {
cl.mesh.rotation.y = Math.sin(t * 0.9 + cl.phase) * 0.18;
}
}
/**
* The portal: a standing stone ring with a viz-fed disc. The disc material is
* returned so the facade can bind the viz texture (emissiveMap) per frame —
* the IMAGE is the video, the ring is the song's stone.
*/
export function buildPortal({ THREE, palette = null } = {}) {
const pal = Array.isArray(palette) && palette.length ? palette : [[0.7, 0.8, 1.0]];
const group = new THREE.Group();
group.name = 'portal';
const stone = stoneMat(THREE, [0.42, 0.42, 0.46]);
const ring = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R, 0.22, 10, 28), stone);
ring.position.y = FLOOR_Y + PORTAL_RING_R + 0.2;
group.add(ring);
for (const side of [-1, 1]) {
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.5), stone);
foot.position.set(side * (PORTAL_RING_R + 0.1), FLOOR_Y + 0.3, 0);
group.add(foot);
}
const discMat = new THREE.MeshStandardMaterial({
color: new THREE.Color(0.02, 0.02, 0.03),
emissive: new THREE.Color(1, 1, 1),
emissiveIntensity: 1.2,
roughness: 0.5, metalness: 0.1,
side: THREE.DoubleSide,
});
const disc = new THREE.Mesh(new THREE.CircleGeometry(PORTAL_RING_R - 0.1, 28), discMat);
disc.position.y = FLOOR_Y + PORTAL_RING_R + 0.2;
disc.name = 'portalDisc';
group.add(disc);
// A seeded accent arc on the ring so the portal belongs to the song.
const c = pal[1 % pal.length] || pal[0];
const arcMat = new THREE.MeshStandardMaterial({
color: new THREE.Color(0.06, 0.06, 0.07),
emissive: new THREE.Color(c[0], c[1], c[2]),
emissiveIntensity: 0.9,
roughness: 0.5,
});
const arc = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R + 0.02, 0.07, 8, 12, Math.PI * 0.7), arcMat);
arc.position.copy(ring.position);
arc.rotation.z = Math.PI * 0.15;
group.add(arc);
group.userData.arcMat = arcMat;
return { group, discMat };
}
+58
View File
@@ -0,0 +1,58 @@
// Shared helpers for the dungeon venue submodules (layout / zones / props /
// hazards). Pure, no THREE, no wall-clock, no Math.random — seeded Rng only,
// all motion analytic f(t, seed) so seek === playback.
//
// A submodule owns ONE portion of the image and the 3D space its objects live
// in. The facade (../dungeon.js) owns the single walk clock and the path;
// every submodule reads (t, plan, pathAt) and never integrates.
import { Rng } from '../../engine/rng.js';
/** Independent seeded stream that never perturbs sibling streams. */
export function forkDungeon(seed, label) {
return new Rng(hashSeed(seed) ^ 0x9e3779b9).fork(String(label));
}
function hashSeed(seed) {
return (seed >>> 0) || 1;
}
/** Analytic wrap: position of a base offset walking at rate dz after time t. */
export function streamWrap(s0, t, dz, span) {
const s = s0 + t * dz;
return s0 + ((((s - s0) % span) + span) % span);
}
/** Smoothstep 0..1. */
export function smooth01(x) {
const t = Math.max(0, Math.min(1, x));
return t * t * (3 - 2 * t);
}
/**
* Crew-style visibility fade: how many of a population with `count` members
* show at a continuous level in [0, ~1.1]. Never toggles — pure f(level,index).
* Same pattern as ride.js crew fade (DESIGN §4: furniture never hard-toggles
* on interpolated state).
*/
export function fadeFor(level, index, count, soft = 0.12) {
const showIndex = index / Math.max(1, count);
return Math.max(0, Math.min(1, (level - showIndex) / soft));
}
/** Segment kinds the layout planner may emit. */
export const SEGMENT_KINDS = [
'hall',
'room',
'forest',
'wallwalk',
];
/** Zone names the zone planner may emit (one per section). */
export const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk'];
/** Prop types in the prop library (primitives only, no glTF). */
export const PROP_TYPES = ['rubble', 'furniture', 'trees', 'rampart', 'torch', 'banner', 'stalagmite'];
/** Hazard types (visual only — the walk sidesteps them). */
export const HAZARD_TYPES = ['gate', 'gap', 'patrol'];
+44
View File
@@ -0,0 +1,44 @@
// Pin one venue shell onto a look.
//
// The same shell-swap in three places: the filmstrip's `venueSel`, the venue
// gallery's venue picker, and the app's `?venueSel=`. Only the shell is
// forced — module/name/archetype. The VenueSpec population stays the census
// one (counts overlap between archetypes), so this is what makes a pinned
// preview the exact image the app reproduces, rather than a nearby one.
import { venues } from './registry.js';
/**
* Force `look.venue` to render as the named venue.
*
* @param {object} look LookSpec from generateLook (mutated in place)
* @param {string} name venue name, e.g. 'Ride'
* @param {object} [opts]
* @param {number} [opts.seed] seed for a from-scratch spec (default 1)
* @param {boolean} [opts.buildIfMissing] build a neutral spec when the
* census produced no venue at all (the venue gallery does; the filmstrip
* keeps the no-venue video instead)
* @returns {boolean} true when a venue is pinned afterwards
*/
export function pinVenueShell(look, name, { seed = 1, buildIfMissing = false } = {}) {
if (!name || !look) return false;
const picked = venues.find((v) => v.name === name);
if (!picked) throw new Error(`unknown venue "${name}"`);
if (!look.venue) {
if (!buildIfMissing) return false;
look.venue = {
archetype: picked.archetype, name: picked.name, seed,
shell: { kind: picked.archetype }, projection: { kind: 'screen' },
population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
paletteMap: [0, 1, 2, 3],
perSection: (look.sections || []).map(
() => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
venue: true, readsDepth: false, module: picked,
};
return true;
}
look.venue.module = picked;
look.venue.name = picked.name;
look.venue.archetype = picked.archetype;
return true;
}
+78
View File
@@ -0,0 +1,78 @@
// Projection helper — viz texture → venue surface (V1 placeholder, no RTT yet).
//
// In V1 compositing stays no-RTT: venue and viz are separate Model/Shader layers
// blended in Compositor. This module is the V1.5 contract so the shape exists
// before the cost does: stage.screen, chamber.window, future passage.skydome
// all share the same `vizTexture → material.map` plumbing at half-res.
//
// V1 implementations are no-ops / DataTexture fallbacks so the module can be
// imported without a WebGL context (tests, headless gallery). V1.5 will swap
// the DataTexture for a real RenderTarget texture.
import * as THREE from 'three';
/**
* Create a venue projection texture at half-res (bloomA size).
* Headless fallback uses DataTexture so `document` is not required.
* @param {number} w width of venue target
* @param {number} h height of venue target
* @returns {THREE.Texture}
*/
export function createVenueTexture(w = 640, h = 360) {
// Try CanvasTexture when DOM exists; otherwise DataTexture.
try {
if (typeof document !== 'undefined' && document.createElement) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.floor(w));
canvas.height = Math.max(1, Math.floor(h));
const g = canvas.getContext('2d');
if (g) {
g.fillStyle = '#101018';
g.fillRect(0, 0, canvas.width, canvas.height);
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.needsUpdate = true;
tex.userData.isVenuePlaceholder = true;
return tex;
}
} catch { /* fall through */ }
const W = Math.max(1, Math.floor(w));
const H = Math.max(1, Math.floor(h));
const data = new Uint8Array(W * H * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = 16; data[i + 1] = 16; data[i + 2] = 24; data[i + 3] = 255;
}
const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat);
tex.needsUpdate = true;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.userData.isVenuePlaceholder = true;
return tex;
}
/**
* Bind a viz texture onto a venue mesh.
* @param {THREE.Mesh} mesh venue hero surface (screen / window / skydome)
* @param {THREE.Texture} vizTexture texture produced from viz layerTarget
* @param {string} mapping 'screen' | 'window' | 'reflections' | 'skydome'
*/
export function bindVenueTexture(mesh, vizTexture, mapping = 'screen') {
if (!mesh || !mesh.material) return;
const mat = mesh.material;
// V1 treats every mapping as emissive screen; V1.5 will specialise.
if (mapping === 'screen' || mapping === 'window') {
mat.map = vizTexture || mat.map;
mat.emissiveMap = vizTexture || mat.emissiveMap;
mat.needsUpdate = true;
} else if (mapping === 'reflections') {
mat.map = vizTexture || mat.map;
mat.roughness = 0.35;
mat.metalness = 0.22;
mat.needsUpdate = true;
} else {
mat.map = vizTexture || mat.map;
mat.needsUpdate = true;
}
}
+38
View File
@@ -0,0 +1,38 @@
// Venue registry — the venue IS the ground.
// Mirrors src/scenes/registry.js pattern: modules carry traits/consumes/venue:true,
// validated by params/schema.js and selected via census (signatureWeight) in
// VenueGenerator. Venue is higher-level than viz — picked first, viz second.
import { validateModule } from '../params/schema.js';
import { chamber } from './chamber.js';
import { stage } from './stage.js';
import { ride } from './ride.js';
import { dungeon } from './dungeon.js';
export const ARCHETYPES = ['chamber', 'stage', 'ride', 'dungeon'];
const MODULES = [chamber, stage, ride, dungeon];
const errors = [];
for (const m of MODULES) {
const e = validateModule(m);
if (e.length) errors.push(...e);
if (!m.archetype || !ARCHETYPES.includes(m.archetype)) errors.push(`${m.name}: unknown archetype '${m.archetype}'`);
if (!m.venue) errors.push(`${m.name}: venue registry entry must have venue:true`);
}
if (errors.length) {
console.error('[venue registry] invalid venue modules:\n' + errors.join('\n'));
}
export const venues = MODULES;
export const venueErrors = errors;
export function venueByName(name) {
return MODULES.find((m) => m.name === name) || null;
}
export function venuesInArchetype(archetype) {
return MODULES.filter((m) => m.archetype === archetype);
}
export default venues;
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// Shared sky dome for model venues: a gradient backdrop derived from the
// song, replacing the clear-to-black background every venue used to sit on.
//
// Why shared: the background is the largest single region in every venue
// frame, and three hand-rolled gradients would drift into three art styles.
// One helper, one grammar — hue from the palette, level from the section,
// texture from the personality — and each venue reads as the same video in a
// different place.
//
// Contract (mirrors the venue module shape, but it is NOT a venue):
// buildSky(THREE, { radius }) -> { mesh, mat } // BackSide sphere shell
// paintSky(mat, { palette, light, style, seed }) // per-frame recolour
//
// Determinism: pure f(inputs), no rng held, no wall-clock. The dome is static
// geometry; only its colours move, and they move with venueState.
/**
* Build the dome mesh. Radius must exceed the venue's far plane content but
* stay inside the camera far (200) and the fog wash — fog applies, which is
* what melts the horizon into the ground.
*/
export function buildSky(THREE, { radius = 90 } = {}) {
const T = THREE;
const mat = new T.ShaderMaterial({
side: T.BackSide,
depthWrite: false,
fog: false,
uniforms: {
u_top: { value: new T.Color(0.05, 0.06, 0.10) },
u_mid: { value: new T.Color(0.10, 0.08, 0.14) },
u_bot: { value: new T.Color(0.02, 0.02, 0.03) },
u_bands: { value: 0.0 },
u_seed: { value: 0.0 },
// The running video, composited elsewhere, overlaid on the dome
// so mirrored bodywork has something true to mirror. Off (0)
// unless a venue binds a texture — chamber and stage never do.
u_viz: { value: null },
u_vizAmount: { value: 0.0 },
},
vertexShader: `
varying vec3 vPos;
void main() {
vPos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 u_top;
uniform vec3 u_mid;
uniform vec3 u_bot;
uniform float u_bands;
uniform float u_seed;
uniform sampler2D u_viz;
uniform float u_vizAmount;
varying vec3 vPos;
// Mirrored spherical projection shared with the ride's paint
// (see vizEnvMaterial): direction to sphere UVs, with the azimuth
// folded into a triangle so the ±π wrap lands on the same texel
// instead of a visible seam. Offset a quarter turn (shared
// constant with ride.js): the fold lines sit fore and aft, where
// a mirrored join never faces the chase camera head-on — on the
// flanks it mirrored the world at the viewer.
vec2 skyVizUv(vec3 dir) {
float u = atan(dir.z, dir.x) * 0.15915494 + 0.5 + 0.25;
u = 1.0 - abs(1.0 - 2.0 * fract(u));
float v = asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988 + 0.5;
return vec2(u, v);
}
void main() {
float h = normalize(vPos).y; // -1 .. 1
vec3 col = h > 0.0
? mix(u_mid, u_top, pow(h, 0.65))
: mix(u_mid, u_bot, pow(clamp(-h * 1.6, 0.0, 1.0), 0.8));
// Faint horizontal banding (strata / city glow lines) when the
// personality asks for texture; silent otherwise.
if (u_bands > 0.001) {
float b = sin(h * 40.0 + u_seed) * 0.5 + 0.5;
b = smoothstep(0.75, 0.95, b) * u_bands;
col += u_top * b * 0.6;
}
// The viz as the sky itself, mixed over the gradient amount.
// Masked to the upper sky: the horizon band stays gradient so
// the ground keeps melting into it (fog-matched), while the
// zenith plays the video.
if (u_vizAmount > 0.001) {
vec3 viz = texture2D(u_viz, skyVizUv(normalize(vPos))).rgb;
float m = smoothstep(0.02, 0.5, h) * clamp(u_vizAmount, 0.0, 1.0);
col = mix(col, viz, clamp(m, 0.0, 1.0));
}
gl_FragColor = vec4(col, 1.0);
}
`,
});
const mesh = new T.Mesh(new T.SphereGeometry(radius, 24, 16), mat);
mesh.name = 'sky';
mesh.frustumCulled = false;
return { mesh, mat };
}
/**
* Recolour the dome from the song: hue from the palette, level from the
* section light, texture from the personality style. All continuous inputs —
* nothing here may step at a boundary.
*/
export function paintSky(mat, { palette, light = 0.7, style = null, seed = 0 } = {}) {
if (!mat) return;
const pal = palette && palette.length ? palette : [[0.5, 0.5, 0.6], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
const u = mat.uniforms;
// Zenith: palette's coolest entry deepened; mid: warmest entry at dusk
// level; nadir: near-black of the first entry (melts into the ground).
const top = pal[2] || pal[0];
const mid = pal[1] || pal[0];
const bot = pal[0];
const lv = 0.35 + light * 0.85;
u.u_top.value.setRGB(top[0] * 0.55 * lv, top[1] * 0.55 * lv, top[2] * 0.75 * lv);
u.u_mid.value.setRGB(mid[0] * 0.5 * lv, mid[1] * 0.42 * lv, mid[2] * 0.5 * lv);
u.u_bot.value.setRGB(bot[0] * 0.12, bot[1] * 0.12, bot[2] * 0.15);
// Texture: grain-handed tracks get strata bands, clean ones a flat grade.
const tex = style && typeof style.texture === 'number' ? style.texture : 0.5;
u.u_bands.value = Math.max(0, Math.min(1, (tex - 0.45) * 2.2));
u.u_seed.value = (seed % 1000) / 1000;
}
+335
View File
@@ -0,0 +1,335 @@
// VENUE: Stage — hall / pylon field. Open ground plane at sigHorizonY(),
// LED wall / hero screen for future RTT, Instanced-like pylons / legs
// from actorToGeometry primitives via stageNode. One venue = one place.
import { actorToGeometry, castShape } from '../actors/meshes.js';
import { buildSky, paintSky } from './sky.js';
export const stage = {
name: 'Stage',
archetype: 'stage',
family: 'structural',
kind: 'model',
venue: true,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'camera', 'style'],
texture: 0.4,
// The stage wall is a 14-unit emissive screen — already the brightest thing
// in the frame — so it blazes rarely, or the wall goes to paper.
reach: 0.55,
params: {
density: { type: 'float', range: [0.3, 1.0], default: 0.55, uniform: 'u_density', bias: 'density' },
spread: { type: 'float', range: [0.6, 1.8], default: 1.1, uniform: 'u_spread' },
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
columns: { type: 'float', range: [2, 6], default: 4, uniform: 'u_columns', bias: 'density', slowAxis: true },
palette: { type: 'palette', count: 4 },
},
reactive: {},
// Venue RTT: the compositor calls this with the finished viz texture after
// compositing the viz stack into vizTarget. The LED wall samples it, so the
// viz appears ON the wall rather than composited over the venue.
bindVizTexture(instance, texture) {
if (!instance || !texture) return;
if (instance.screenMat && instance.screenMat.emissiveMap !== texture) {
instance.screenMat.emissiveMap = texture;
instance.screenMat.needsUpdate = true;
}
},
// Glowing screen base like the chamber's — arcs with venueState.light.
// See Layer.washColor.
washColor(instance) {
const st = (instance && instance.venueState) || null;
const light = st ? st.light : 0.7;
let r = 0.05, g = 0.05, b = 0.07;
if (instance && instance.groundMat) {
const c = instance.groundMat.color;
r = c.r; g = c.g; b = c.b;
}
const glow = (0.07 + light * 0.09);
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
const groundGeo = new T.PlaneGeometry(44, 44);
const groundMat = new T.MeshStandardMaterial({
color: new T.Color(0.13, 0.13, 0.15),
roughness: 0.82,
metalness: 0.05,
});
const ground = new T.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.15;
ground.receiveShadow = false;
scene.add(ground);
// LED wall — the viz render surface. 14×7.875 is 16:9 like the viz
// that feeds it, so the image arrives undistorted; emissiveMap carries
// the viz (bound per-frame by bindVizTexture), emissive color stays
// near-white so the viz keeps its own colors.
const screenGeo = new T.PlaneGeometry(14, 7.875);
const screenMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.0,
roughness: 0.7,
metalness: 0.1,
});
const screen = new T.Mesh(screenGeo, screenMat);
screen.position.set(0, 2.2, -14.5);
screen.name = 'screen';
scene.add(screen);
const ambient = new T.AmbientLight(0xffffff, 0.55);
scene.add(ambient);
const key = new T.DirectionalLight(0xffffff, 1.15);
key.position.set(2.4, 4.8, 3.0);
key.castShadow = false;
scene.add(key);
const fill = new T.DirectionalLight(0xffffff, 0.32);
fill.position.set(-2.8, 1.6, -2.2);
scene.add(fill);
scene.background = null;
scene.fog = new T.Fog(0x0a0a0f, 10, 26);
scene.userData.transparentBackground = true;
// Song sky over the open field — radius 90 clears the 44-unit ground.
const sky = buildSky(T, { radius: 90 });
scene.add(sky.mesh);
const pylonContainer = new T.Group();
pylonContainer.name = 'pylons';
scene.add(pylonContainer);
return {
ground, groundMat, screen, screenMat, ambient, key, fill,
skyMesh: sky.mesh, skyMat: sky.mat,
pylonContainer,
seed,
venueSpec: venueSpec || null,
builtCols: -1,
builtKey: null,
pylonCount: 0,
};
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
const T = THREE;
const t = timeline.time;
const beat = features.beat || 0;
void beat;
const bandLow = features.bandLow || 0;
const identity = personality ? personality.identity : null;
const shape = personality ? personality.shape : null;
const space = personality ? personality.space : null;
const camTrait = personality ? personality.camera : null;
const style = personality ? personality.style : null;
const shapeTilt = shape ? shape.tilt : 0;
const shapeSides = shape ? shape.sides : 0;
const spaceHorizon = space ? space.horizon : 0.5;
const camSway = camTrait ? camTrait.sway : 0;
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
const camSpin = camTrait ? camTrait.spin : 0;
const styleWeight = style ? style.lineWeight : 0.5;
void shapeSides;
const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8],[0.4,0.6,0.9],[0.9,0.5,0.4]];
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
const ink = identity ? identity.ink : null;
const lattice = identity ? identity.lattice : null;
void lattice;
const elementScale = identity ? identity.lattice.elementScale : 0.35;
void elementScale;
const stageScaleVal = elementScale / 0.35;
const latticeKind = lattice ? lattice.kind : 'grid';
void stageScaleVal; void latticeKind;
// Materials from palette. The wall IMAGE is the viz texture
// (emissiveMap); emissive color stays near-white so the viz keeps its
// own colors, and story light moves intensity only.
const c0 = pal[0];
// The field breathes with the story arc like the chamber room does —
// a static ground dilutes section changes into churn. Hue from the
// palette, level from the field (same unlightable-dark fix as chamber).
const fieldLight = vState ? vState.light : 0.7;
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
const fieldLevel = (0.18 + fieldLight * 0.26) * comp;
instance.groundMat.color.setRGB(
(c0[0] / tMax) * fieldLevel, (c0[1] / tMax) * fieldLevel, (c0[2] / tMax) * fieldLevel);
if (instance.key) instance.key.intensity = 0.5 + fieldLight * 1.0;
if (instance.ambient) instance.ambient.intensity = 0.3 + fieldLight * 0.45;
instance.screenMat.emissive.setRGB(1, 1, 1);
{ const l = vState ? vState.light : 1; instance.screenMat.emissiveIntensity = (params.light || 0.85) * (0.65 + l * 0.6); }
// Ground level follows horizon
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.6;
instance.ground.position.y = groundY;
// Song sky + matched fog, same contract as chamber.
paintSky(instance.skyMat, {
palette: pal, light: fieldLight, style,
seed: instance.seed,
});
if (scene.fog) {
const fogMul = vState ? vState.fog : 0.35; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 18;
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
if (mid) scene.fog.color.copy(mid);
else scene.fog.color.setRGB(c0[0]*0.07, c0[1]*0.07, c0[2]*0.09);
}
// Column count is locked at first build: it used to re-derive from live
// params.columns (which drifts) and bandLow (which pumps), so the whole
// pylon field rebuilt itself mid-song whenever drift crossed an integer
// — the object-jump this change kills. A story jump may re-hang the
// field via the arrangement salt; nothing else rebuilds it.
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
instance.venueArrangement = arrangement;
let cols = instance.builtCols;
if (!(cols >= 2)) {
cols = Math.max(2, Math.min(6, Math.round(((params.columns || 7) - 3) * 0.4 + 3 + bandLow * 0.1)));
}
const rows = Math.max(2, Math.min(3, 3));
const identityKey = identity ? `${identity.cast.protagonist.sides}:${identity.ink.fill}:${latticeKind}:${actorSpec?actorSpec.seed:0}:${vs?vs.seed:0}:a${arrangement}` : `no-id:${actorSpec?actorSpec.seed:0}:a${arrangement}`;
const needRebuild = instance.builtKey !== identityKey || !instance.pylonCount;
if (needRebuild && identity && actorSpec && actorSpec.form) {
for (const child of [...instance.pylonContainer.children]) {
instance.pylonContainer.remove(child);
child.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
}
});
}
let legShapeGeo = null;
try {
const s = castShape(identity.cast.chorus, 20);
legShapeGeo = new T.ExtrudeGeometry(s, { depth: 0.42, bevelEnabled: false });
legShapeGeo.translate(0,0,-0.21);
} catch { legShapeGeo = new T.BoxGeometry(0.45,0.45,0.42); }
const baseRough = 0.42 + styleWeight*0.12 + (ink?ink.weight*0.08:0);
// Crowns are ~1 unit wide at their part-scale, so columns get ~2.5+
// units of lateral room and rows ~7 of depth, or neighbours
// interpenetrate. cols/rows are already capped above to what fits:
// fewer, wider-spaced pylons read as architecture, not clutter.
for (let r=0; r<rows; r++) {
const f = rows>1 ? r/(rows-1) : 0;
const scale = 1*(1-f) + 0.18*f;
const y0 = groundY + f*0.2;
const zBase = -f*7.5 - 0.5;
for (let c=0; c<cols; c++) {
const pylon = new T.Group();
// Fixed spacing per column, not a fixed total width: adding
// columns widens the field instead of squeezing neighbours
// together. ~2.9 units apart at spread 1.1 for ~1-unit crowns.
pylon.position.set((c - (cols - 1) / 2) * (params.spread || 1.1) * 2.6, 0, zBase);
pylon.userData.f = f;
// Crown — one part of the track's body per pylon, dealt round
// the field like cards (pylon i takes part i % parts). Every
// crown was the whole assembly at 0.25 scale, so the field
// read as N identical grey thumbnails; dealing parts makes
// neighbours differ the way limbs differ.
const crownGroup = new T.Group();
crownGroup.name='crown';
try {
const built = actorToGeometry(actorSpec, identity, T);
const parts = built.children.slice();
const src = parts.length
? parts[(c + r * cols) % parts.length]
: null;
if (src) {
const m = new T.Mesh(
src.geometry,
new T.MeshStandardMaterial({ color: new T.Color(1, 1, 1) }),
);
m.userData.partIndex = src.userData.partIndex ?? 0;
const cc = pal[(m.userData.partIndex||0) % pal.length] || pal[0];
m.material = new T.MeshStandardMaterial({ color: new T.Color(cc[0],cc[1],cc[2]), roughness: baseRough, metalness: 0.07, transparent: opacity<0.999, opacity });
crownGroup.add(m);
} else {
throw new Error('empty body');
}
} catch {
const g=new T.BoxGeometry(0.2,0.2,0.2);
const m=new T.Mesh(g,new T.MeshStandardMaterial({color: new T.Color(c0[0],c0[1],c0[2])}));
crownGroup.add(m);
}
const h = 0.75*scale*1.1;
crownGroup.position.set(0, y0 + h + 0.06*scale, 0);
// Capped: elementScale runs to ~2.5× typical on huge-form
// tracks, and an uncapped crown outgrows its column spacing.
crownGroup.scale.setScalar((0.38+0.42*scale)*Math.min(stageScaleVal, 1.5)*0.9);
crownGroup.userData.yawSeed = c*0.9 + r*0.4 + shapeTilt*0.4;
pylon.add(crownGroup);
// Leg rung
const rung = new T.Mesh(legShapeGeo.clone(), new T.MeshStandardMaterial({ color: new T.Color(1,1,1), roughness: 0.55 }));
rung.scale.set((h/2)*0.9, (h/2)*0.9, 0.85);
rung.position.set(0, y0 + h*0.45, 0);
const lc = pal[(c+r)%pal.length];
rung.material.color.setRGB(lc[0],lc[1],lc[2]);
rung.material.transparent = opacity<0.999;
rung.material.opacity = opacity*0.95;
rung.userData.yawSeed = r*0.6 + c*0.3;
pylon.add(rung);
pylon.userData.crown = crownGroup;
pylon.userData.rung = rung;
pylon.userData.y0 = y0;
instance.pylonContainer.add(pylon);
}
}
if (legShapeGeo) legShapeGeo.dispose();
instance.builtCols = cols;
instance.builtKey = identityKey;
instance.pylonCount = cols*rows;
instance.needsCompile = true;
}
// Analytic pose
for (const pylon of instance.pylonContainer.children) {
const crown = pylon.userData.crown;
const rung = pylon.userData.rung;
// Far rows thin out in sparse setups via popMul — as a SMOOTH
// opacity fade, never a visible toggle: the interpolated
// venueState crosses thresholds at arbitrary mid-section moments
// (same pattern as the ride's crew and the chamber's junk).
const popMul = vState ? vState.popMul : 1;
const fi = pylon.userData.f || 0; // far rows more sensitive
const fade = Math.max(0, Math.min(1, ((0.35 + popMul * 0.75) - fi) / 0.12));
pylon.visible = opacity > 0.01 && fade > 0.01;
if (crown) {
const yaw = t*0.35 + crown.userData.yawSeed + shapeTilt*0.2;
crown.rotation.order='YXZ';
crown.rotation.set(0.12+Math.sin(t*0.28 + pylon.userData.f)*0.14, yaw, Math.sin(t*0.16)*0.08);
for (const m of crown.children) if(m.isMesh){ m.material.opacity=opacity*fade; m.material.transparent=opacity*fade<0.999; m.visible=fade>0.01; }
}
if (rung) {
rung.rotation.order='YXZ';
rung.rotation.set(0, t*0.22 + rung.userData.yawSeed, 0);
rung.material.opacity=opacity*0.95*fade;
rung.material.transparent=opacity*fade<0.999;
rung.visible=fade>0.01;
}
}
// Field drift
instance.pylonContainer.position.x = Math.sin(t*camSwayRate)*camSway*0.5;
instance.pylonContainer.position.z = Math.cos(t*camSwayRate*0.7)*camSway*0.3;
instance.pylonContainer.rotation.y = camSpin*t*0.03;
void t; void params;
},
};
export default stage;
+576
View File
@@ -0,0 +1,576 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Rng } from '../src/engine/rng.js';
import { genDungeon, dungeonCharacter, dungeonTheme, THEMES, THEME_NAMES, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from '../src/venues/dungeon/map.js';
import { PROP_BUILDERS } from '../src/venues/dungeon/props.js';
import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js';
import { generateVenue } from '../src/venues/VenueGenerator.js';
import { generatePersonality } from '../src/look/Personality.js';
test('dungeon map is deterministic with rooms, zones and a closed route', () => {
const a = genDungeon({ seed: 7, rng: new Rng(7) });
const b = genDungeon({ seed: 7, rng: new Rng(7) });
assert.deepEqual(a, b, 'same seed must generate the same dungeon');
assert.ok(a.rooms.length >= 3, `only ${a.rooms.length} rooms`);
const zones = new Set(a.rooms.map((r) => r.zone));
assert.ok(zones.size >= 3, `zone coverage thin: ${[...zones].join(',')}`);
assert.ok(a.route.totalLen > 80, `route too short: ${a.route.totalLen}`);
// pathAt wraps and is pure.
const s1 = pathAt(a, 3.7), s2 = pathAt(a, 3.7 + a.route.totalLen);
assert.deepEqual(s1, s2, 'pathAt must wrap seamlessly');
assert.ok(Number.isFinite(s1.yaw), 'heading must be finite');
});
test('every room is reachable: BFS over open cells hits all rooms', () => {
for (const seed of [5, 7, 4242]) {
const m = genDungeon({ seed, rng: new Rng(seed) });
const seen = new Set([0]);
const r0 = m.rooms[0];
const qx = [r0.x + Math.floor(r0.w / 2)], qy = [r0.y + Math.floor(r0.h / 2)];
const seenCells = new Set([qy[0] * m.w + qx[0]]);
const roomOf = new Int16Array(m.w * m.h).fill(-1);
for (const rm of m.rooms) {
for (let y = rm.y; y < rm.y + rm.h; y++) {
for (let x = rm.x; x < rm.x + rm.w; x++) roomOf[y * m.w + x] = rm.id;
}
}
let head = 0;
while (head < qx.length) {
const cx = qx[head], cy = qy[head];
head++;
if (roomOf[cy * m.w + cx] >= 0) seen.add(roomOf[cy * m.w + cx]);
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
if (!m.grid[ny * m.w + nx] || seenCells.has(ny * m.w + nx)) continue;
seenCells.add(ny * m.w + nx);
qx.push(nx); qy.push(ny);
}
}
assert.equal(seen.size, m.rooms.length,
`seed ${seed}: reachable ${seen.size}/${m.rooms.length} rooms`);
}
});
test('the route only walks open cells and visits every room', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
// Route samples sit on open cells (checked at cell granularity).
for (let s = 0; s < m.route.totalLen; s += 2) {
const p = pathAt(m, s);
const cx = Math.floor(p.x / CELL + m.w / 2);
const cy = Math.floor(p.z / CELL + m.h / 2);
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `route off the grid at s=${s}`);
assert.ok(m.grid[cy * m.w + cx], `route crosses a solid cell at s=${s.toFixed(1)}`);
}
const visited = new Set(m.route.roomIds.filter((id) => id >= 0));
assert.equal(visited.size, m.rooms.length, 'the tour must enter every room');
});
test('no holes indoors, green edges outdoors: every open edge is walled or planted', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
const hRuns = m.wallRuns.filter((r) => r.z0 === r.z1);
const vRuns = m.wallRuns.filter((r) => r.x0 === r.x1);
const coveredH = (x, z) => hRuns.some((r) =>
Math.abs(r.z0 - z) < 1e-6 && x + 1e-6 >= r.x0 && x - 1e-6 <= r.x1);
const coveredV = (x, z) => vRuns.some((r) =>
Math.abs(r.x0 - x) < 1e-6 && z + 1e-6 >= r.z0 && z - 1e-6 <= r.z1);
const plantedNear = (x, z) => m.items.vegetation.some((v) =>
Math.hypot(v.x - x, v.z - z) < 1.6);
const w0 = (cx) => (cx - m.w / 2) * CELL; // west edge of cell cx
const z0 = (cy) => (cy - m.h / 2) * CELL;
// Indoor-ness is the map's own flag (rooms by rect, corridors by the
// rooms they join) — the test reads it, never re-derives it, so a
// corridor the tour never walks still judges correctly.
const cellIndoor = (x, y) => m.indoor[y * m.w + x] === 1;
let walled = 0, planted = 0;
for (let y = 0; y < m.h; y++) {
for (let x = 0; x < m.w; x++) {
if (!m.grid[y * m.w + x]) continue;
const openN = (nx, ny) => nx >= 0 && ny >= 0 && nx < m.w && ny < m.h && m.grid[ny * m.w + nx];
const edges = [];
if (!openN(x - 1, y)) edges.push(['v', w0(x), z0(y) + CELL / 2]);
if (!openN(x + 1, y)) edges.push(['v', w0(x) + CELL, z0(y) + CELL / 2]);
if (!openN(x, y - 1)) edges.push(['h', w0(x) + CELL / 2, z0(y)]);
if (!openN(x, y + 1)) edges.push(['h', w0(x) + CELL / 2, z0(y) + CELL]);
for (const [kind, ex, ez] of edges) {
const covered = kind === 'h' ? coveredH(ex, ez) : coveredV(ex, ez);
if (cellIndoor(x, y)) {
assert.ok(covered, `hole on ${kind === 'h' ? 'horizontal' : 'vertical'} edge of (${x},${y})`);
walled++;
} else {
assert.ok(plantedNear(ex, ez) || covered,
`outdoor edge of (${x},${y}) neither walled nor planted`);
planted++;
}
}
}
}
assert.ok(walled > 100, `only ${walled} wall edges checked — map too open?`);
assert.ok(planted > 20, `only ${planted} planted edges — outdoor bare?`);
for (const v of m.items.vegetation) {
assert.ok(['tree', 'bush', 'boulder'].includes(v.kind), `unknown planting ${v.kind}`);
const cx = Math.floor(v.x / CELL + m.w / 2);
const cy = Math.floor(v.z / CELL + m.h / 2);
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, 'planting off the grid');
assert.ok(!m.grid[cy * m.w + cx], 'planting on an open cell — it would block the walk');
}
});
test('watch cells stand on open ground with line-of-sight to the route', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
assert.ok(Object.keys(m.watch).length >= m.rooms.length - 1,
'nearly every room needs a camera chair');
for (const [id, w] of Object.entries(m.watch)) {
const cx = Math.floor(w.x / CELL + m.w / 2);
const cy = Math.floor(w.z / CELL + m.h / 2);
assert.ok(m.grid[cy * m.w + cx], `watch cell of room ${id} is solid`);
const rm = m.rooms.find((r) => r.id === Number(id));
assert.ok(cx >= rm.x && cx < rm.x + rm.w && cy >= rm.y && cy < rm.y + rm.h,
`watch cell of room ${id} is outside its room`);
}
});
test('portal sits off-route in a room, facing the walk', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
assert.ok(m.portal, 'no portal planned');
const cx = Math.floor(m.portal.x / CELL + m.w / 2);
const cy = Math.floor(m.portal.z / CELL + m.h / 2);
assert.ok(m.grid[cy * m.w + cx], 'portal cell is solid');
const rm = m.rooms.find((r) => r.id === m.portal.roomId);
assert.ok(rm && rm.indoor, 'portal belongs in an indoor room');
assert.ok(Number.isFinite(m.portal.yaw), 'portal yaw must be finite');
});
test('items sit on open cells, off the walked line', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
const onOpen = (x, z, what) => {
const cx = Math.floor(x / CELL + m.w / 2);
const cy = Math.floor(z / CELL + m.h / 2);
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `${what} off the grid`);
assert.ok(m.grid[cy * m.w + cx], `${what} on a solid cell`);
};
for (const p of m.items.props) {
assert.ok(PROP_BUILDERS[p.type], `unknown prop ${p.type}`);
onOpen(p.x, p.z, `prop ${p.type}`);
}
assert.ok(m.items.torches.length >= 2, `only ${m.items.torches.length} torches`);
assert.ok(m.items.torches.length <= 18, 'torch cap blown — clutter');
for (const tc of m.items.torches) {
// Sconces hang on indoor stone: an indoor open cell within a step,
// with a solid neighbour in reach of the bracket — never floating,
// never buried deep.
const cx = Math.floor(tc.x / CELL + m.w / 2);
const cy = Math.floor(tc.z / CELL + m.h / 2);
let indoorNear = false, solidNear = false, openNear = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
if (m.grid[ny * m.w + nx]) {
openNear = true;
if (m.indoor[ny * m.w + nx]) indoorNear = true;
} else solidNear = true;
}
}
assert.ok(openNear, 'torch buried in stone');
assert.ok(indoorNear, 'torch burning outdoors');
assert.ok(solidNear, 'torch with no wall in reach — floating');
}
assert.ok(m.items.patrols.length >= 1, 'no patrol lanterns for the open ground');
assert.ok(m.items.windows.length >= 4, `only ${m.items.windows.length} windows`);
assert.ok(m.items.puddles.length === Math.max(2, Math.round(4 * THEMES[m.theme].puddleMul)),
`puddle count ${m.items.puddles.length} ignores theme ${m.theme}`);
for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) {
onOpen(g.x, g.z, 'hazard');
}
for (const g of m.items.gates) {
assert.ok(HAZARD_BUILDERS.gate, 'gate builder missing');
assert.ok(typeof g.s === 'number', 'gate needs route arclength for the lift');
}
// Avoidance stays bounded on the real gate list.
for (let i = 0; i < 50; i++) {
const off = avoidanceOffset(i * 1.7, m.items.gates, i * 0.63);
assert.ok(Number.isFinite(off) && Math.abs(off) <= 1.0, `avoidance out of bounds: ${off}`);
}
});
test('portcullis stays up for the whole procession, not just the hero', async () => {
const T = await import('three');
const { updateHazards, HAZARD_BUILDERS } = await import('../src/venues/dungeon/hazards.js');
const gate = HAZARD_BUILDERS.gate(T, new Rng(3), [0.45, 0.45, 0.48]);
gate.userData.home = { x: 0, z: 0, yaw: 0 };
gate.userData.hazardS = 100;
const liftOf = (partyS) => {
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: partyS[0], partyS });
return gate.userData.liftBars.position.y;
};
// Hero under the gate: fully raised.
assert.ok(liftOf([100]) > 2.3, 'gate not raised for the hero');
// Hero through, one trailer still under it: must stay fully raised.
assert.ok(liftOf([104, 100.5, 108]) > 2.3, 'gate dropped on the trailer');
// Whole party clear: back down.
assert.ok(liftOf([112, 116, 120]) < 0.1, 'gate stays up with nobody near');
// Hero-only fallback still behaves (no partyS passed).
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: 100 });
assert.ok(gate.userData.liftBars.position.y > 2.3, 'hero-only lift broken');
});
test('roomAt/zoneAt follow the route through rooms and corridors', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
const seenRooms = new Set(), seenZones = new Set();
let corridor = false;
for (let s = 0; s < m.route.totalLen; s += 1) {
const r = roomAt(m, s);
const z = zoneAt(m, s);
assert.ok(Number.isInteger(r) && r >= -1 && r < m.rooms.length, `bad room ${r}`);
assert.ok(typeof z === 'string' && z.length > 0, `bad zone ${z}`);
if (r >= 0) seenRooms.add(r);
else corridor = true;
seenZones.add(z);
}
assert.equal(seenRooms.size, m.rooms.length, 'route samples must cover every room');
assert.ok(corridor, 'route never leaves rooms — corridors unused?');
assert.ok(seenZones.size >= 2, 'route crosses a single zone only');
});
test('the cast assembles: enemies, critters and a wisp on open cells', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
const kinds = m.items.entities.map((e) => e.kind);
assert.ok(kinds.filter((k) => k === 'lurker').length >= 2, `lurkers: ${kinds.join(',')}`);
assert.ok(kinds.includes('critter'), 'no critters');
assert.ok(kinds.includes('wisp'), 'no wisp');
assert.equal(m.items.entities.length, 6, `cast size ${m.items.entities.length}`);
for (const e of m.items.entities) {
assert.ok(['lurker', 'sentry', 'critter', 'wisp'].includes(e.kind), `unknown entity ${e.kind}`);
const cx = Math.floor(e.x / CELL + m.w / 2);
const cy = Math.floor(e.z / CELL + m.h / 2);
assert.ok(m.grid[cy * m.w + cx] === 1, `${e.kind} on a solid cell`);
assert.ok(Number.isFinite(e.s) && Number.isFinite(e.yaw), `${e.kind} missing s/yaw`);
assert.ok(e.range >= 0 && Number.isFinite(e.speed) && Number.isFinite(e.phase),
`${e.kind} missing gait params`);
}
});
test('loot sits a step off the walk, each cache earning its halt', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
assert.ok(m.items.loot.length >= 2, `only ${m.items.loot.length} loot spots`);
assert.ok(m.items.loot.length <= 3, 'loot glut');
for (const l of m.items.loot) {
assert.ok(['cache', 'relic', 'hoard'].includes(l.kind), `unknown loot ${l.kind}`);
const cx = Math.floor(l.x / CELL + m.w / 2);
const cy = Math.floor(l.z / CELL + m.h / 2);
assert.ok(m.grid[cy * m.w + cx] === 1, `loot ${l.kind} on a solid cell`);
}
assert.equal(m.items.halts.length, m.items.loot.length, 'every cache earns a halt');
for (let i = 0; i < m.items.halts.length; i++) {
const h = m.items.halts[i];
assert.ok(h.dur > 0 && h.dur <= 3, `halt dur ${h.dur}`);
assert.equal(h.s, m.items.loot[i].s, 'halt must sit on its loot');
for (let j = i + 1; j < m.items.halts.length; j++) {
const L = m.route.totalLen;
const d = Math.abs(h.s - m.items.halts[j].s) % L;
assert.ok(Math.min(d, L - d) >= 15, 'halts overlap — the odometer could retreat');
}
}
});
test('odometer holds at halts and never retreats', () => {
const cruise = 1.4, L = 200;
const halts = [{ s: 50, dur: 2.2 }, { s: 150, dur: 2.2 }];
// Deterministic.
assert.equal(odometer(cruise, halts, L, 10), odometer(cruise, halts, L, 10));
// Unpaused stretch advances at gait.
assert.ok(Math.abs(odometer(cruise, halts, L, 10) - cruise * 10) < 1e-9, 'gait wrong');
// Arrival holds: from t_a to t_a+dur the reading stays on the loot.
const ta = 50 / cruise;
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 1.1) - 50) < 1e-9, 'halt does not hold');
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 2.2) - 50) < 1e-9, 'halt releases early');
// Monotonic everywhere, both gaits, both halt sets.
for (const c of [0.4, 1.4, 2.6]) {
let prev = odometer(c, halts, L, 0);
for (let t = 0.05; t < 300; t += 0.05) {
const s = odometer(c, halts, L, t);
const d = (s - prev + L) % L;
assert.ok(d < c * 0.06 + 1e-9, `retreat at t=${t.toFixed(2)} (cruise ${c})`);
prev = s;
}
}
});
test('entity poses are deterministic, bounded and hero-aware', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
const L = m.route.totalLen;
for (const e of m.items.entities) {
const p1 = entityPose(e, 10, 5, 0, 0, L);
const p2 = entityPose(e, 10, 5, 0, 0, L);
assert.deepEqual(p1, p2, `${e.kind} pose unstable`);
assert.ok(Math.hypot(p1.x - e.x, p1.z - e.z) <= e.range + 1.6,
`${e.kind} wanders ${Math.hypot(p1.x - e.x, p1.z - e.z).toFixed(2)} from home`);
assert.ok(p1.alert >= 0 && p1.alert <= 1, `${e.kind} alert out of range`);
// Near the hero it notices; far away it doesn't.
const near = entityPose(e, 10, e.s, e.x, e.z, L);
const far = entityPose(e, 10, (e.s + L / 2) % L, e.x, e.z, L);
assert.ok(near.alert >= far.alert, `${e.kind} ignores the hero`);
assert.ok(near.alert > 0.5, `${e.kind} cold at point blank`);
}
});
const SLOW_DARK = {
meanCentroid: 0.3, meanFlatness: 0.1, bpm: 68,
dynamicRange: 0.7, meanLoudness: 0.3,
};
// Summaries that pin the character mapping at its corners: a sealed crypt
// (dark + slow) and an airy ruin (bright + fast).
const SEALED = { bpm: 70, meanCentroid: 0.05, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.4 };
const AIRY = { bpm: 140, meanCentroid: 0.9, meanFlatness: 0.7, meanLoudness: 0.0014, dynamicRange: 0.89 };
test('dungeonCharacter maps song to dungeon in documented ranges', () => {
for (const summary of [{}, SEALED, AIRY, SLOW_DARK, { bpm: 200, meanCentroid: 5, meanLoudness: -1 }]) {
const c = dungeonCharacter(summary);
for (const k of ['indoorBias', 'murk', 'sizeMul', 'dark', 'slow']) {
assert.ok(Number.isFinite(c[k]), `${k} not finite for ${JSON.stringify(summary)}`);
}
assert.ok(c.indoorBias >= 0 && c.indoorBias <= 1, 'indoorBias out of range');
assert.ok(c.murk >= 0 && c.murk <= 1, 'murk out of range');
assert.ok(c.sizeMul >= 1 && c.sizeMul <= 1.45, 'sizeMul out of range');
}
// Defaults are the neutral middle, not an extreme.
const d0 = dungeonCharacter({});
assert.ok(d0.indoorBias > 0.3 && d0.indoorBias < 0.9, 'default bias extreme');
assert.ok(d0.murk > 0.1 && d0.murk < 0.8, 'default murk extreme');
// Monotonic in darkness: darker songs seal and murk harder.
const brighter = dungeonCharacter({ ...SEALED, meanCentroid: 0.5 });
const sealed = dungeonCharacter(SEALED);
assert.ok(sealed.indoorBias >= brighter.indoorBias, 'darker song less sealed');
assert.ok(sealed.murk >= brighter.murk, 'darker song less murky');
// Monotonic in tempo: slower songs seal harder and build bigger.
// (bpm 140 stays unfolded — above 150 the detector doubles, so the
// comparison stays in honest tempo territory.)
const faster = dungeonCharacter({ ...SEALED, bpm: 140 });
assert.ok(sealed.indoorBias >= faster.indoorBias, 'slower song less sealed');
assert.ok(sealed.sizeMul >= faster.sizeMul, 'slower song builds smaller');
});
test('dark slow songs seal the dungeon: no outside views', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7), summary: SEALED });
assert.equal(m.character.indoorBias, 1, 'sealed summary must pin bias');
assert.ok(m.rooms.length >= 3, 'sealed map needs rooms too');
for (const rm of m.rooms) {
assert.ok(rm.indoor, `sealed room ${rm.id} left open (${rm.zone})`);
}
for (let s = 0; s < m.route.totalLen; s += 2) {
const z = zoneAt(m, s);
assert.ok(z === 'hall' || z === 'room', `sealed route sees outside: ${z}`);
}
});
test('bright fast songs open the dungeon: some outside views', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7), summary: AIRY });
assert.ok(m.character.indoorBias < 0.5, 'airy summary must drop bias');
assert.ok(m.rooms.some((rm) => !rm.indoor), 'airy map sealed shut');
assert.ok(m.rooms.some((rm) => rm.indoor), 'airy map has no interior at all');
});
test('heavy songs build bigger rooms than light ones, same seed', () => {
const heavy = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 70, meanCentroid: 0.3, meanLoudness: 0.01 } });
const light = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 140, meanCentroid: 0.6, meanLoudness: 0.001 } });
assert.ok(heavy.character.sizeMul > light.character.sizeMul + 0.2, 'heft must move sizeMul');
const mean = (p) => p.rooms.reduce((a, r) => a + r.w * r.h, 0) / p.rooms.length;
assert.ok(mean(heavy) > mean(light) * 1.2,
`heavy mean area ${mean(heavy).toFixed(1)} vs light ${mean(light).toFixed(1)}`);
assert.ok(heavy.rooms.length >= 3 && light.rooms.length >= 3, 'both maps need rooms');
});
test('timber frames every gate and beams some walls, bounded', () => {
const m = genDungeon({ seed: 7, rng: new Rng(7) });
assert.ok(m.items.gates.length > 0, 'seed 7 needs gates for this test');
assert.ok(m.items.timbers.length > 0, 'no timber at all');
assert.ok(m.items.timbers.length <= 140, `timber blowout: ${m.items.timbers.length}`);
for (const tb of m.items.timbers) {
for (const k of ['x', 'y', 'z', 'sx', 'sy', 'sz', 'yaw']) {
assert.ok(Number.isFinite(tb[k]), `timber ${k} not finite`);
}
assert.ok(tb.sx > 0 && tb.sy > 0 && tb.sz > 0, 'timber with dead dimension');
}
// Every gate gets a door frame: two posts flanking it plus a lintel over.
for (const g of m.items.gates) {
const rx = Math.cos(g.yaw), rz = -Math.sin(g.yaw);
const near = m.items.timbers.filter((tb) =>
Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6);
assert.ok(near.length >= 3, `gate at ${g.x.toFixed(1)},${g.z.toFixed(1)} has ${near.length} timbers`);
const posts = near.filter((tb) => Math.abs(tb.sy - 2.7) < 1e-9);
assert.equal(posts.length, 2, `gate frame wants 2 posts, has ${posts.length}`);
for (const side of [-1.15, 1.15]) {
assert.ok(posts.some((tb) =>
Math.abs((tb.x - g.x) * rx + (tb.z - g.z) * rz - side) < 0.35),
`gate frame missing post at lateral ${side}`);
}
assert.ok(near.some((tb) => Math.abs(tb.sx - 2.75) < 1e-9 && tb.y > -1.15 + 2.4),
'gate frame missing its lintel');
}
// Wall beams sit on wall runs (plate centres), never floating.
const beams = m.items.timbers.filter((tb) => Math.abs(tb.sx - 0.3) > 1e-9 || Math.abs(tb.sz - 0.3) > 1e-9);
assert.ok(beams.length > 0, 'no wall beams sampled');
const onRun = (tb) => m.wallRuns.some((r) => {
const len = Math.max(r.x1 - r.x0, r.z1 - r.z0);
const d = len > 0
? Math.abs((tb.x - r.x0) * (r.z1 - r.z0) - (tb.z - r.z0) * (r.x1 - r.x0)) / len
: Math.hypot(tb.x - r.x0, tb.z - r.z0);
const within = tb.x >= Math.min(r.x0, r.x1) - 0.4 && tb.x <= Math.max(r.x0, r.x1) + 0.4
&& tb.z >= Math.min(r.z0, r.z1) - 0.4 && tb.z <= Math.max(r.z0, r.z1) + 0.4;
return within && d < 0.35;
});
for (const tb of beams) {
// Door-frame pieces live at gates, not on runs — skip those.
const atGate = m.items.gates.some((g) => Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6);
if (atGate) continue;
assert.ok(onRun(tb), `beam at ${tb.x.toFixed(1)},${tb.z.toFixed(1)} floats off its wall`);
}
});
function slowPersonalityAt(seed) {
const rng = new Rng(seed);
return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4);
}
test('VenueGenerator plans the dungeon map when the dungeon is picked', () => {
const rng = new Rng(5);
const story = {
sections: [{ act: 'setup' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }],
};
const vs = generateVenue({
summary: SLOW_DARK, rng: rng.fork('venue'), personality: slowPersonalityAt(5),
director: null, story, sections: [{}, {}, {}, {}],
});
assert.equal(vs.archetype, 'dungeon');
assert.ok(vs.dungeon && vs.dungeon.map, 'dungeon venue must carry the map');
assert.ok(vs.dungeon.map.rooms.length >= 3, 'map has no rooms');
assert.ok(vs.dungeon.map.route.totalLen > 80, 'route too short');
assert.equal(vs.population.kind, 'party', 'dungeon walks a party, not a crowd');
assert.equal(vs.projection.kind, 'portal', 'dungeon projects on the portal');
// Deterministic across regenerations.
const again = generateVenue({
summary: SLOW_DARK, rng: new Rng(5).fork('venue'), personality: slowPersonalityAt(5),
director: null, story, sections: [{}, {}, {}, {}],
});
assert.deepEqual(again.dungeon, vs.dungeon);
// Non-dungeon venues carry no dungeon plan.
const fast = generateVenue({
summary: { ...SLOW_DARK, bpm: 140 }, rng: new Rng(5).fork('venue'),
personality: slowPersonalityAt(5), director: null, story, sections: [{}, {}, {}, {}],
});
if (fast.archetype !== 'dungeon') assert.equal(fast.dungeon, null);
});
const THEME_CORNERS = {
crypt: { bpm: 62, meanCentroid: 0.02, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.9 },
overgrown: { bpm: 100, meanCentroid: 0.45, meanFlatness: 0.85, meanLoudness: 0.0005, dynamicRange: 0.5 },
ember: { bpm: 150, meanCentroid: 0.5, meanFlatness: 0.3, meanLoudness: 0.0022, dynamicRange: 0.4 },
flooded: { bpm: 120, meanCentroid: 0.9, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.9 },
windswept: { bpm: 170, meanCentroid: 0.85, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.4 },
};
test('dungeonTheme is deterministic and names a known theme', () => {
for (const s of [{}, ...Object.values(THEME_CORNERS)]) {
assert.equal(dungeonTheme(s, 7), dungeonTheme(s, 7), 'theme unstable across calls');
assert.ok(THEME_NAMES.includes(dungeonTheme(s, 7)), `unknown theme for ${JSON.stringify(s)}`);
assert.ok(THEMES[dungeonTheme(s, 7)], 'theme record missing');
}
// Same song, different seeds may vary (jitter), but the pick stays valid.
for (const seed of [1, 2, 3, 99, 12345]) {
assert.ok(THEME_NAMES.includes(dungeonTheme(THEME_CORNERS.ember, seed)));
}
});
test('each theme wins its home corner', () => {
for (const [want, s] of Object.entries(THEME_CORNERS)) {
assert.equal(dungeonTheme(s, 7), want, `${want} corner picked ${dungeonTheme(s, 7)}`);
assert.equal(dungeonTheme(s, 99), want, `${want} corner unstable across seeds`);
}
});
test('theme sweep reaches every theme', () => {
const seen = new Set();
for (let bpm = 60; bpm <= 180; bpm += 20) {
for (const cent of [0.1, 0.4, 0.7, 0.95]) {
for (const flat of [0.1, 0.6, 0.9]) {
for (const loud of [0.0005, 0.002]) {
seen.add(dungeonTheme({ bpm, meanCentroid: cent, meanFlatness: flat, meanLoudness: loud, dynamicRange: 0.5 }, 7));
}
}
}
}
assert.deepEqual([...seen].sort(), [...THEME_NAMES].sort(),
`sweep reaches only ${[...seen].join(',')}`);
});
test('theme records are well-formed', () => {
for (const [name, th] of Object.entries(THEMES)) {
for (const k of ['stoneMul', 'floorMul', 'torch', 'fogTint', 'wood']) {
assert.ok(Array.isArray(th[k]) && th[k].length === 3, `${name}.${k} not an RGB triple`);
assert.ok(th[k].every((v) => Number.isFinite(v) && v >= 0 && v <= 1.2), `${name}.${k} out of range`);
}
for (const k of ['propIndoor', 'propOutdoor']) {
assert.ok(Array.isArray(th[k]) && th[k].length > 0, `${name}.${k} empty`);
assert.ok(th[k].every((t) => PROP_BUILDERS[t]), `${name}.${k} names an unbuildable prop`);
}
for (const k of ['vegMul', 'timberMul', 'puddleMul']) {
assert.ok(Number.isFinite(th[k]) && th[k] > 0, `${name}.${k} not a positive density`);
}
}
});
test('theme reshapes the plan: dressing, planting, timber, puddles', () => {
// Ember-forcing vs crypt-forcing summaries, same seed: only the theme differs.
const ember = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.ember });
const crypt = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.crypt });
assert.equal(ember.theme, 'ember');
assert.equal(crypt.theme, 'crypt');
const stal = (m) => m.items.props.filter((p) => p.type === 'stalagmite').length;
assert.ok(stal(ember) >= stal(crypt), `ember ${stal(ember)} vs crypt ${stal(crypt)} stalagmites`);
assert.ok(ember.items.puddles.length <= crypt.items.puddles.length,
`ember ${ember.items.puddles.length} vs crypt ${crypt.items.puddles.length} puddles`);
assert.ok(ember.items.timbers.length < crypt.items.timbers.length,
`ember ${ember.items.timbers.length} vs crypt ${crypt.items.timbers.length} timbers`);
});
test('facade builds themed materials and lights', async () => {
const T = await import('three');
const { dungeon } = await import('../src/venues/dungeon.js');
const summary = THEME_CORNERS.ember;
const map = genDungeon({ seed: 7, rng: new Rng(7), summary });
const theme = THEMES[map.theme];
const scene = new T.Scene();
const instance = dungeon.build({
scene, seed: 7, params: {},
actorSpec: null,
venueSpec: { seed: 5, dungeon: { map }, population: { count: 2 }, paletteMap: [0, 1, 2, 3], perSection: [] },
THREE: T,
});
assert.ok(instance, 'build returned nothing');
const flame = instance.flameMat.emissive;
assert.ok(Math.abs(flame.r - theme.torch[0]) < 1e-6
&& Math.abs(flame.g - theme.torch[1]) < 1e-6
&& Math.abs(flame.b - theme.torch[2]) < 1e-6,
`flame ${flame.r.toFixed(2)},${flame.g.toFixed(2)},${flame.b.toFixed(2)} != theme ${theme.torch}`);
const tl = instance.torch.color;
assert.ok(Math.abs(tl.r - theme.torch[0]) < 1e-6
&& Math.abs(tl.g - theme.torch[1]) < 1e-6
&& Math.abs(tl.b - theme.torch[2]) < 1e-6,
'torch light ignores theme');
// Per-frame palette tint carries the theme multipliers.
dungeon.update({
instance, scene, camera: new T.PerspectiveCamera(),
timeline: { time: 1, frame: 60, progress: 0.01 },
features: {}, params: {}, palette: [[1, 1, 1]],
personality: null, framing: null, opacity: 1,
actorSpec: null, venueSpec: instance.venueSpec, venueState: null,
THREE: T,
});
const st = instance.stoneMat.color;
const fx = theme.stoneMul[0];
assert.ok(Math.abs(st.r / fx - (0.42 * (0.45 + 0.7 * 0.75) + 0.07)) < 0.05,
'stone tint does not carry the theme multiplier');
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clampEdits, buildTweakPrompt, TweakResultSchema, ALLOWED_KEYS } from '../src/llm/spec.js';
import { LLM_BUDGET, getBudgetCounts, canInvoke, remainingText } from '../src/llm/budget.js';
import { LLM_DEFAULTS, LLM_STORAGE_KEY } from '../src/llm/config.js';
test('clampEdits clamps to valid ranges and drops invalid arrangement', () => {
const out = clampEdits({ bpm: 300, brightness: 1.5, noise: -1, arrangement: 'nope', dynamics: 0.1234 });
assert.equal(out.bpm, 180);
assert.equal(out.brightness, 1);
assert.equal(out.noise, 0);
assert.equal(out.arrangement, undefined);
assert.equal(out.dynamics, 0.12);
});
test('clampEdits preserves noiseColour null and rounds', () => {
assert.deepEqual(clampEdits({ noiseColour: null }), { noiseColour: null });
assert.deepEqual(clampEdits({ noiseColour: 0.3333 }), { noiseColour: 0.33 });
assert.deepEqual(clampEdits({ arrangement: 'classic' }), { arrangement: 'classic' });
});
test('ALLOWED_KEYS covers the 7 song knobs', () => {
assert.deepEqual([...ALLOWED_KEYS].sort(), ['arrangement', 'bpm', 'brightness', 'dynamics', 'noise', 'noiseColour', 'transition'].sort());
});
test('buildTweakPrompt returns system+user messages and respects hint', () => {
const fakeDigest = {
describe: 'seed abc · geometer · triad',
director: 'geometer',
paletteScheme: 'triad',
sections: [{ index: 0, kind: 'intro', name: 'Shot', layers: 'Shot' }],
validation: null,
};
const msgs = buildTweakPrompt({
spec: { bpm: 124, brightness: 0.5, noise: 0.2, noiseColour: null, dynamics: 0.55, transition: 0.6, arrangement: 'classic', duration: 120, seed: 42 },
track: { summary: { bpm: 124, meanCentroid: 0.5, meanFlatness: 0.2, dynamicRange: 0.55, meanLoudness: 0.4, tempoConfidence: 0.9 }, sections: [{ kind: 'intro', start: 0, end: 15, energy: 0.2 }], duration: 120 },
lookDigest: fakeDigest,
hint: 'make the drop calmer',
});
assert.equal(msgs.length, 2);
assert.equal(msgs[0].role, 'system');
assert.equal(msgs[1].role, 'user');
assert.ok(msgs[0].content.includes('make the drop calmer') || msgs[0].content.includes('User hint'));
assert.ok(msgs[1].content.includes('songSpec'));
});
test('buildTweakPrompt truncates long hint in system', () => {
const long = 'x'.repeat(1000);
const msgs = buildTweakPrompt({ spec: null, track: null, lookDigest: null, hint: long });
assert.ok(msgs[0].content.length < 5000);
});
test('TweakResultSchema validates a minimal valid result', () => {
const parsed = TweakResultSchema.parse({
rationale: 'Lower noise to calm the drop.',
edits: { bpm: null, brightness: null, noise: 0.3, noiseColour: null, dynamics: null, transition: null, arrangement: null },
confidence: 0.8,
});
assert.equal(parsed.edits.noise, 0.3);
assert.equal(parsed.edits.bpm, null);
assert.ok(parsed.rationale.length > 0);
});
test('budget defaults are sane and canInvoke reports not-configured without a key', () => {
assert.equal(LLM_BUDGET.perLoad, 3);
assert.equal(LLM_BUDGET.perSession, 10);
assert.equal(LLM_STORAGE_KEY, 'flow-state:llm');
assert.equal(LLM_DEFAULTS.gatewayUrl, 'https://ai-gateway.vercel.sh/v1');
const counts = getBudgetCounts();
assert.ok(counts.perLoad === 3 && counts.perSession === 10);
assert.ok(typeof counts.loadCount === 'number' && typeof counts.sessionCount === 'number');
const gate = canInvoke();
assert.equal(gate.ok, false);
assert.equal(gate.reason, 'not-configured');
assert.ok(typeof remainingText() === 'string');
});
+427
View File
@@ -0,0 +1,427 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { venues } from '../src/venues/registry.js';
import { generateVenue } from '../src/venues/VenueGenerator.js';
import { generateActorSet } from '../src/actors/ActorGenerator.js';
import { generatePersonality } from '../src/look/Personality.js';
import { generateIdentity } from '../src/look/Identity.js';
import { Rng } from '../src/engine/rng.js';
import { Compositor } from '../src/engine/Compositor.js';
const SUMMARY = {
meanCentroid: 0.5, meanFlatness: 0.15, bpm: 120,
dynamicRange: 0.6, meanLoudness: 0.4,
};
function personalityAt(seed) {
const rng = new Rng(seed);
return generatePersonality(SUMMARY, rng.fork('personality'), () => 10, 4);
}
test('every venue declares a reach rating in (0, 2]', () => {
assert.ok(venues.length > 0, 'no venues registered');
for (const v of venues) {
assert.equal(typeof v.reach, 'number', `${v.name}: missing reach rating`);
assert.ok(v.reach > 0 && v.reach <= 2, `${v.name}: reach ${v.reach} outside (0, 2]`);
}
});
test('venue spec carries a stable seed and per-section evolution', () => {
const rng = new Rng(1234);
const story = {
sections: [
{ act: 'setup' }, { act: 'development' },
{ act: 'climax' }, { act: 'resolution' },
],
};
const venue = generateVenue({
summary: SUMMARY, rng: rng.fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{}, {}, {}, {}],
});
assert.ok(venue.seed !== undefined && Number.isInteger(venue.seed));
assert.equal(venue.perSection.length, 4);
// Climax is the brightest, most populated point of the arc.
assert.ok(venue.perSection[2].light >= venue.perSection[0].light,
'climax should be at least as bright as setup');
assert.ok(venue.perSection[2].popMul >= venue.perSection[3].popMul,
'climax should be at least as populated as resolution');
// Deterministic: same seed, same spec.
const again = generateVenue({
summary: SUMMARY, rng: new Rng(1234).fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{}, {}, {}, {}],
});
assert.equal(again.seed, venue.seed);
assert.deepEqual(again.perSection, venue.perSection);
assert.deepEqual(again.rearrangeAt, venue.rearrangeAt);
});
test('rearrangeAt only jumps on act changes, and the recap returns home', () => {
const story = {
recap: true,
sections: [
{ act: 'setup' }, { act: 'development' }, { act: 'development' },
{ act: 'climax' }, { act: 'resolution' },
],
};
const mk = (seed) => generateVenue({
summary: SUMMARY, rng: new Rng(seed).fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{ kind: 'intro' }, { kind: 'build' }, { kind: 'drop' }, { kind: 'drop' }, { kind: 'outro' }],
});
const venue = mk(777);
assert.equal(venue.rearrangeAt.length, 5);
// Same-act section never rearranges.
assert.equal(venue.rearrangeAt[2], venue.rearrangeAt[1],
'same-act section must keep its arrangement');
// Recap outro returns to the intro's room.
assert.equal(venue.rearrangeAt[4], venue.rearrangeAt[0],
'recap outro must return to arrangement 0');
// Arrangement ids never decrease except at the recap.
for (let i = 1; i < 4; i++) {
assert.ok(venue.rearrangeAt[i] >= venue.rearrangeAt[i - 1],
`arrangement went backwards at section ${i}`);
}
// Stable across regenerations.
assert.deepEqual(mk(777).rearrangeAt, venue.rearrangeAt);
});
test('venue room camera walks on a bounded, deterministic path', () => {
// No WebGL needed: drive the camera method on a bare object.
const comp = Object.create(Compositor.prototype);
comp.sharedCamera = {
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
rotation: {},
lookAt() {},
updateMatrixWorld() {},
};
const personality = personalityAt(7);
const venue = {
archetype: 'chamber', seed: 4242,
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 }],
};
const at = (time, journey) => {
comp._updateVenueCamera(comp.sharedCamera, {
framing: { scale: 1, shift: [0, 0] },
personality, time, venue, journey,
// Production always passes progress (Show.js); the journey-only
// fallback exists for hand-built paths. Measure the real path.
progress: time / 400,
});
return { ...comp.sharedCamera.position };
};
const a = at(10, 0.2), b = at(40, 0.6), c = at(10, 0.2);
// Deterministic in (time, journey): same inputs, same position.
assert.deepEqual(a, c);
// It moves over 30 s — this is the device under test.
const travelled = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
assert.ok(travelled > 0.15, `camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
// Bounded: a walk that leaves the room fails the device.
for (const t of [0, 30, 90, 200, 400]) {
const p = at(t, 0.5);
assert.ok(Math.abs(p.x) < 2.6, `x=${p.x.toFixed(2)} outside the room at t=${t}`);
assert.ok(p.z > 0.4 && p.z < 8, `z=${p.z.toFixed(2)} outside the room at t=${t}`);
}
// Stage walks further than chamber (bigger room). The traverse is
// journey-driven, so both probes move in journey as well as time.
const stageAt = (time, journey) => {
comp._updateVenueCamera(comp.sharedCamera, {
framing: { scale: 1, shift: [0, 0] },
personality, time, venue: { ...venue, archetype: 'stage' }, journey,
progress: time / 400,
});
return { ...comp.sharedCamera.position };
};
const s0 = stageAt(0, 0.2), s1 = stageAt(37.5, 0.6);
const chamberSpan = Math.hypot(b.x - a.x, b.y - a.y);
const stageSpan = Math.hypot(s1.x - s0.x, s1.y - s0.y);
assert.ok(stageSpan >= chamberSpan * 0.9,
`stage walk (${stageSpan.toFixed(2)}) should reach at least as far as chamber (${chamberSpan.toFixed(2)})`);
});
test('each venue module builds and updates headless with a furnished actor', () => {
// THREE is ESM-only but importable from node; geometry build paths need no GL.
const THREE = awaitImportThree();
return THREE.then((T) => {
const personality = personalityAt(21);
const identity = generateIdentity(SUMMARY, new Rng(21).fork('identity'), 4);
personality.identity = identity;
const actors = generateActorSet(SUMMARY, new Rng(21).fork('actors'), personality, identity);
for (const v of venues) {
const scene = new T.Scene();
const instance = v.build({
scene, seed: 99, params: {}, actorSpec: actors.monolith,
venueSpec: {
seed: 5, population: { count: 4, spread: 0.7, jitter: 0.25 },
paletteMap: [0, 1, 2, 3],
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
},
THREE: T,
});
assert.ok(instance, `${v.name}: build returned nothing`);
v.update({
instance, scene, camera: new T.PerspectiveCamera(),
timeline: { time: 12.5, frame: 750, progress: 0.1 },
features: {}, params: { light: 0.7, columns: 7, spread: 1.1 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.4, 0.3]],
personality, framing: { scale: 1, shift: [0, 0] }, opacity: 1,
actorSpec: actors.monolith,
venueSpec: instance.venueSpec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
const furnished = countMeshes(scene);
assert.ok(furnished >= 2,
`${v.name}: only ${furnished} mesh(es) after update with an actor — furniture did not build`);
}
});
});
function countMeshes(root) {
let n = 0;
root.traverse((o) => { if (o.isMesh) n++; });
return n;
}
// NOTE: ArcDriver persistence/interpolation/jump logic is exercised in the
// browser (checks + filmstrip), not here: ArcDriver's import chain reads
// scenes/metadata.json, which Node 20 cannot import without an attribute.
// See tools/venue-check.js --filmstrip for the carryover measurement.
test('ride declares directCamera and drives its own rig deterministically', () => {
const THREE = awaitImportThree();
return THREE.then((T) => {
const rideMod = venues.find((v) => v.name === 'Ride');
assert.ok(rideMod, 'Ride venue not registered');
assert.equal(rideMod.directCamera, true, 'Ride must own its camera rig');
const personality = personalityAt(11);
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
personality.identity = identity;
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
const scene = new T.Scene();
const instance = rideMod.build({
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
venueSpec: {
seed: 5, population: { count: 3, spread: 0.6, jitter: 0.2 },
paletteMap: [0, 1, 2, 3],
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
},
THREE: T,
});
const stubCam = () => ({
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
rotation: {},
look: null,
lookAt(x, y, z) { this.look = [x, y, z]; },
updateMatrixWorld() {},
});
const drive = (time, framing) => {
const cam = stubCam();
rideMod.update({
instance, scene, camera: cam,
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
features: {}, params: { light: 0.85, speed: 0.7 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
personality, framing, opacity: 1,
actorSpec: actors.monolith,
venueSpec: instance.venueSpec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
const { x, y, z } = cam.position;
return { x, y, z, look: cam.look };
};
const framing = { scale: 1, shift: [0, 0] };
const a = drive(10, framing), b = drive(10, framing);
assert.deepEqual(a, b, 'same inputs must place the camera identically');
const c = drive(40, framing);
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
assert.ok(travelled > 0.15, `ride camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
// Stays inside its own box (wider + behind the car, unlike rooms —
// must match the clamp in driveRideCamera).
for (const t of [0, 30, 90, 200, 400]) {
const p = drive(t, framing);
assert.ok(Math.abs(p.x) <= 6.0, `x=${p.x.toFixed(2)} outside the ride box at t=${t}`);
assert.ok(p.z >= -6.0 && p.z <= 6.0, `z=${p.z.toFixed(2)} outside the ride box at t=${t}`);
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
}
// A planned mark steers the head: screen vs room framings look apart.
const shot = { pos: [1, 0.5, 4], look: [0, 0.8, -0.8], size: 'close', target: 'screen', dolly: 0.72 };
const roomShot = { pos: [-1, 0.5, 5], look: [0, 0.3, -6], size: 'wide', target: 'room', dolly: 1.3 };
const s1 = drive(60, { scale: 1, shift: [0, 0], venueShot: shot });
const s2 = drive(60, { scale: 1, shift: [0, 0], venueShot: roomShot });
assert.ok(Math.hypot(s1.look[0] - s2.look[0], s1.look[1] - s2.look[1]) > 0.2,
'screen vs room marks must aim the camera apart');
});
});
// Dynamic import indirection so a missing/broken three surfaces as a test
// failure with a name on it rather than a top-level load error for the file.
function awaitImportThree() {
return import('three');
}
test('dungeon declares directCamera and plants its cameras on open cells', () => {
const THREE = awaitImportThree();
return THREE.then(async (T) => {
const dungeonMod = venues.find((v) => v.name === 'Dungeon');
assert.ok(dungeonMod, 'Dungeon venue not registered');
assert.equal(dungeonMod.directCamera, true, 'Dungeon must own its camera rig');
const personality = personalityAt(11);
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
personality.identity = identity;
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
const scene = new T.Scene();
const dungeonSpec = {
seed: 5, population: { count: 3, spread: 0.5, jitter: 0.1 },
paletteMap: [0, 1, 2, 3],
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
};
const instance = dungeonMod.build({
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
venueSpec: dungeonSpec, THREE: T,
});
const stubCam = () => ({
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
rotation: {},
look: null,
lookAt(x, y, z) { this.look = [x, y, z]; },
updateMatrixWorld() {},
});
const drive = (time, framing) => {
const cam = stubCam();
dungeonMod.update({
instance, scene, camera: cam,
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
features: {}, params: { light: 0.85, speed: 0.7 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
personality, framing, opacity: 1,
actorSpec: actors.monolith,
venueSpec: instance.venueSpec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
const { x, y, z } = cam.position;
return { x, y, z, look: cam.look };
};
const framing = { scale: 1, shift: [0, 0] };
const a = drive(10, framing), b = drive(10, framing);
assert.deepEqual(a, b, 'same inputs must place the camera identically');
const c = drive(40, framing);
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
assert.ok(travelled > 0.15, `dungeon camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
// Every camera — walk or room plant — stands on an open cell at eye
// height, and always looks somewhere. The map is 88 units wide, so
// positions roam; openness is the invariant, not a small box.
const cellOpen = (x, z) => {
const map = instance.map;
const cx = Math.floor(x / 2 + map.w / 2);
const cy = Math.floor(z / 2 + map.h / 2);
return cx >= 0 && cy >= 0 && cx < map.w && cy < map.h && map.grid[cy * map.w + cx] === 1;
};
const watches = [
framing,
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1, 2], look: [0, 0.2, -5], size: 'normal', target: 'walk', dolly: 1.0 } },
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1.5, 3], look: [0, 0.9, -3], size: 'normal', target: 'room', dolly: 1.0 } },
{ scale: 1, shift: [0, 0], venueShot: { pos: [2.6, 1.5, 1.0], look: [-0.5, 1.3, -8], size: 'wide', target: 'portal', dolly: 1.3, drive: 0.5 } },
];
for (const t of [0, 30, 90, 200, 400]) {
for (const f of watches) {
const p = drive(t, f);
assert.ok(cellOpen(p.x, p.z), `camera inside a wall at t=${t} (${p.x.toFixed(2)},${p.z.toFixed(2)})`);
assert.ok(p.y >= 0.2 && p.y <= 3.4, `y=${p.y.toFixed(2)} off eye height at t=${t}`);
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
// The bearer hides in exactly the frames the camera stands
// where he is: first-person never renders him, plants do.
assert.equal(instance.hero.visible, instance.watchRoom < 0 ? false : true,
`hero visibility disagrees with watchRoom at t=${t}`);
}
}
// Walk vs room aim apart: the modes are the coverage.
const m1 = drive(60, watches[1]);
const m2 = drive(60, watches[2]);
const m3 = drive(60, watches[3]);
assert.ok(Math.hypot(m1.x - m2.x, m1.z - m2.z) > 0.2,
'walk vs room must place the camera apart');
assert.ok(Math.hypot(m1.look[0] - m3.look[0], m1.look[2] - m3.look[2]) > 0.2,
'walk vs portal must aim apart');
// The portal ring sits on an open cell and the tableau camera aims
// at the live actor, not at a conventional mark.
const ring = instance.portal.group.position;
assert.ok(cellOpen(ring.x, ring.z), 'portal ring inside a wall');
const hero = instance.hero.position;
assert.deepEqual([m3.look[0], m3.look[2]], [hero.x, hero.z],
'tableau camera must track the actor');
// Enemies wear the song's actor pool: grown bodies from this track's
// walker/structure/swarm ActorSpecs, darkened hostile with ember
// eyes set into the face the geometry actually has.
const grown = (instance.cast || []).filter((en) => en.grown);
assert.ok(grown.length > 0, 'cast never grew its song bodies');
// Grown from the song's actor pool, not the venue monolith: the
// cached specs carry walker/structure/swarm archetypes.
const specs = instance.enemySpecs || {};
for (const [kind, arch] of [['lurker', 'walker'], ['sentry', 'structure'], ['critter', 'swarm']]) {
if (!specs[kind]) continue;
assert.equal(specs[kind].archetype, arch,
`${kind} enemy grown from ${specs[kind].archetype}, not ${arch}`);
assert.notEqual(specs[kind].seed, actors.monolith.seed,
`${kind} enemy reuses the monolith seed`);
}
for (const en of grown) {
if (en.spec.kind === 'wisp') continue;
assert.ok(en.bodyMesh && en.bodyMesh.isMesh, `${en.spec.kind} has no body`);
assert.ok(en.eyes.length >= 2, `${en.spec.kind} has no eyes`);
// The body is this song's cast, not a stock primitive: its part
// is tagged with the kind it plays.
assert.equal(en.bodyMesh.userData.kind, en.spec.kind,
`${en.spec.kind} body is not tagged from the actor pool`);
const bb = en.bodyMesh.geometry.boundingBox;
assert.ok(bb, `${en.spec.kind} body has no measured box`);
const h = (bb.max.y - bb.min.y) * en.bodyMesh.scale.x;
assert.ok(Math.abs(h - en.height) < 0.05,
`${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`);
}
// Murk dims the venue: the same update on a murky map must read
// darker lights and closer fog than on a clear one.
{
const { genDungeon } = await import('../src/venues/dungeon/map.js');
const murky = genDungeon({ seed: 4242, summary: { bpm: 70, meanCentroid: 0.05, meanLoudness: 0.008 } });
const clear = genDungeon({ seed: 4242, summary: { bpm: 170, meanCentroid: 0.6, meanLoudness: 0.001 } });
assert.ok(murky.character.murk > clear.character.murk + 0.3, 'murk spread too small');
const buildMurky = (map) => {
const sc = new T.Scene();
const spec = { ...dungeonSpec, seed: 6, dungeon: { map } };
const inst = dungeonMod.build({
scene: sc, seed: 4242, params: {}, actorSpec: actors.monolith,
venueSpec: spec, THREE: T,
});
const cam = stubCam();
dungeonMod.update({
instance: inst, scene: sc, camera: cam,
timeline: { time: 10, frame: 600, progress: 0.05 },
features: {}, params: { light: 0.85, speed: 0.7 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
personality, framing, opacity: 1,
actorSpec: actors.monolith,
venueSpec: spec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
return { inst, fogNear: sc.fog ? sc.fog.near : Infinity };
};
const murkyBuilt = buildMurky(murky);
const clearBuilt = buildMurky(clear);
const murkyInst = murkyBuilt.inst, clearInst = clearBuilt.inst;
assert.ok(murkyInst.ambient.intensity < clearInst.ambient.intensity,
'murk must dim ambient');
assert.ok(murkyInst.key.intensity < clearInst.key.intensity,
'murk must dim the key');
assert.ok(murkyInst.camFill.intensity < clearInst.camFill.intensity,
'murk must dim the lantern fill');
assert.ok(murkyBuilt.fogNear < clearBuilt.fogNear,
'murk must pull fog closer');
}
});
});
+22 -9
View File
@@ -22,7 +22,7 @@ import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { synthesizeSong } from '../src/audio/synth.js';
import { FeatureTrack } from '../src/audio/FeatureTrack.js';
import { SONGS, AXES } from '../src/audio/songbank.js';
import { SONGS, AXES, bankPcm } from '../src/audio/songbank.js';
const here = dirname(fileURLToPath(import.meta.url));
@@ -41,7 +41,9 @@ const CHECK_ONLY = has('check');
* 16-bit PCM WAV, mono.
*
* Mono halves the size and loses nothing: the analyser sums to mono before it
* does anything, and the synth writes the same signal to both channels anyway.
* does anything, the synth writes the same signal to both channels anyway —
* and the probes analyse the mono bank PCM, so the files must be mono for
* app and probe features to match bit-exactly. See songbank.js bankPcm.
*/
function wav(samples, sampleRate) {
const bytes = samples.length * 2;
@@ -63,13 +65,12 @@ function wav(samples, sampleRate) {
// Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down
// into 16-bit noise and their measured flatness stops being what the spec
// asked for — the bank would then be testing the encoder, not the generator.
let peak = 0;
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
const gain = peak > 1e-9 ? 0.89 / peak : 1;
// Shared with songbank.js bankPcm: the files must hold exactly what the
// probes analyse, or app-vs-probe previews can never match.
const pcm = bankPcm(samples);
for (let i = 0; i < samples.length; i++) {
const v = Math.max(-1, Math.min(1, samples[i] * gain));
buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
for (let i = 0; i < pcm.length; i++) {
buffer.writeInt16LE(Math.round(pcm[i] * 32768), 44 + i * 2);
}
return buffer;
}
@@ -92,7 +93,19 @@ for (const spec of SONGS) {
transition: spec.transition ?? 0.5,
seed: 1 + SONGS.indexOf(spec),
});
const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
// Measured from the bank PCM — the same samples the files hold and the
// probes analyse (see songbank.js bankPcm) — not the raw synth.
const pcm = bankPcm(buffer.getChannelData(0));
const track = FeatureTrack.fromAudioBuffer({
sampleRate: buffer.sampleRate,
length: pcm.length,
duration: buffer.duration,
numberOfChannels: 1,
getChannelData: (i) => {
if (i !== 0) throw new Error('bank is mono');
return pcm;
},
}, { fps: 60 });
if (!CHECK_ONLY) {
writeFileSync(resolve(OUT, `${spec.name}.wav`),
+45 -1
View File
@@ -39,7 +39,7 @@ const FORBIDDEN = [
];
// Directories whose output must be a pure function of (seed, params, frame).
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors'];
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors', 'venues'];
// Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']);
@@ -378,6 +378,50 @@ console.log('\nscene schema lint');
}
}
// A — whole-screen comfort: canvas scenes (≥50% coverage) that swerve
// on the beat drive the ENTIRE view on every kick (≈2 Hz at 124 BPM).
// Those are capped centrally in engine/Layer.js (CANVAS_BEAT_CAP=0.15
// + spike→smooth, FLUX 0.25) so one change tames every canvas. This lint
// is the feedback: a declaration above the cap still builds (clamped at
// runtime) but is warned here so the author can lower it or localise the
// response to a region. Staleness via checks/metadata.js.
for (const [pname, pr] of Object.entries(module.reactive || {})) {
const feat = pr.feature;
const amt = typeof pr.amount === 'number' ? pr.amount : 0;
if (feat === 'beat' && amt > 0.15) {
const cov = (() => {
try {
const m = JSON.parse(readFileSync(join(root, 'src/scenes/metadata.json'), 'utf8'));
const row = m?.scenes?.[module.name];
return typeof row?.coverage === 'number' ? row.coverage : null;
} catch { return null; }
})();
const isCanvas = cov !== null ? cov >= 0.5 : false;
if (isCanvas) {
// Warning, not fail — A works via runtime clamp; the
// declaration should be lowered in B when the effect is
// localised to a region. Keep lint green while the clamp is
// the back-stop.
console.warn(` ⚠ ${id}: reactive '${pname}' beat ${amt.toFixed(2)} on canvas (cov ${(cov*100).toFixed(0)}%) — ` +
`whole-screen jumps; runtime capped to 0.15 (lower or localise the response)`);
}
}
if (feat === 'flux' && amt > 0.25) {
const cov = (() => {
try {
const m = JSON.parse(readFileSync(join(root, 'src/scenes/metadata.json'), 'utf8'));
const row = m?.scenes?.[module.name];
return typeof row?.coverage === 'number' ? row.coverage : null;
} catch { return null; }
})();
const isCanvas = cov !== null ? cov >= 0.5 : false;
if (isCanvas) {
console.warn(` ⚠ ${id}: reactive '${pname}' flux ${amt.toFixed(2)} on canvas (cov ${(cov*100).toFixed(0)}%) — ` +
`runtime capped to 0.25`);
}
}
}
// Rate params: anything the shader multiplies absolute time by must be
// flagged `rate: true`, which stops reactivity and drift from touching it.
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// Scaffold a new venue — mirrors tools/new-scene.js --model.
// Venue IS the ground (role:'ground'), kind:'model' with build/update stubs
// that map venueSpec.perSection + stageNode pattern.
// Usage: node tools/new-venue.js "Harbour" --archetype=chamber
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const VENUES = join(root, 'src/venues');
const REGISTRY = join(root, 'src/venues/registry.js');
const ARCHETYPES = ['chamber','stage','ride','dungeon'];
const args = process.argv.slice(2);
const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
const name = args.find(a=>!a.startsWith('--'));
if (!name) { console.error(`usage: node tools/new-venue.js "Name" --archetype=<${ARCHETYPES.join('|')}>`); process.exit(1); }
const archetype = String(flags.get('archetype')||'chamber');
if (!ARCHETYPES.includes(archetype)) { console.error(`unknown archetype '${archetype}'`); process.exit(1); }
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
const camel = kebab.replace(/-([a-z0-9])/g, (_,c)=>c.toUpperCase());
const file = join(VENUES, `${kebab}.js`);
if (existsSync(file)) { console.error(`${kebab}.js already exists`); process.exit(1); }
const salt = (text, lo, hi)=>{ let h=2166136261; for(let i=0;i<text.length;i++){ h^=text.charCodeAt(i); h=Math.imul(h,16777619)>>>0; } return +(lo+(h%1000)/1000*(hi-lo)).toFixed(2); };
const spread = salt(kebab, 0.5, 1.1);
const light = salt(kebab+'light', 0.5, 0.9);
const source = `// VENUE: ${name} — ${archetype} archetype. One venue = one place.
// Primitives only in V1; per-section evolution via venueState {light,fog,popMul,reachMul} from VenueSpec.perSection.
import { actorToGeometry } from '../actors/meshes.js';
export const ${camel} = {
name: '${name.trim()}',
archetype: '${archetype}',
family: '${archetype==='chamber'?'minimal':'structural'}',
kind: 'model',
venue: true,
consumes: ['form','ink','staging'],
traits: ['shape','space','style'],
texture: 0.35,
// How much viz bloom this room can take before its highlights clip.
// Dim rooms (plaster, floor light) ~1.1; rooms with a big emissive screen ~0.55.
reach: 0.9,
params: {
density: { type: 'float', range: [0.2, 0.9], default: ${spread}, uniform: 'u_density', bias: 'density' },
light: { type: 'float', range: [0.3, 1.2], default: ${light}, uniform: 'u_light', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
const group = new T.Group(); group.name='${kebab}';
const mat = new T.MeshStandardMaterial({ color: new T.Color(0.14,0.13,0.12), roughness: 0.85 });
const floor = new T.Mesh(new T.PlaneGeometry(22,22), mat);
floor.rotation.x = -Math.PI/2; floor.position.y = -1.4; group.add(floor);
scene.add(group);
const amb = new T.AmbientLight(0xffffff, 0.42); scene.add(amb);
scene.background = null; scene.fog = new T.Fog(0x0e0e12, 12, 28); scene.userData.transparentBackground = true;
const pop = new T.Group(); pop.name='pop'; scene.add(pop);
return { group, mat, amb, pop, seed, venueSpec: venueSpec||null, venueState: null, builtKey: null, popCount: 0 };
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, THREE }) {
const T = THREE; const t = timeline.time;
const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8]];
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
if (vState) instance.venueState = vState;
const c0 = pal[0];
instance.mat.color.setRGB(c0[0]*0.26, c0[1]*0.24, c0[2]*0.28);
if (scene.fog && vState) { const fogMul = vState.fog; const space = personality?.space; const base = space ? 12 + space.depth*6 : 15; scene.fog.near = base * (0.85 + fogMul*0.4); }
if (vState) {
const target = 0.55 * (params.light||0.7) * (0.75 + vState.light*0.5);
// stub emissive if needed: venue surface placeholder
void target;
}
const identity = personality?.identity || null;
if (vs && identity && actorSpec && actorSpec.form && (!instance.popCount || instance.builtKey !== \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`)) {
// rebuild pop on spec change — stub: real venue would populate via stageNode/actorToGeometry
instance.builtKey = \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`;
instance.popCount = vs.population.count;
}
// Gate visibility by popMul roughly
if (vState) {
const mul = vState.popMul; const floorCount = Math.max(1, Math.floor((instance.popCount||0) * (0.3 + mul*0.8)));
for (let i=0;i<instance.pop.children.length;i++) instance.pop.children[i].visible = i < floorCount && opacity>0.01;
}
void T; void t; void params; void opacity;
},
};
export default ${camel};
`;
writeFileSync(file, source);
let registry = readFileSync(REGISTRY,'utf8');
const importLine = `import { ${camel} } from './${kebab}.js';`;
if (!registry.includes(importLine)) {
const lastImport = registry.lastIndexOf("} from './");
const eol = registry.indexOf('\n', lastImport);
registry = registry.slice(0,eol+1)+importLine+'\n'+registry.slice(eol+1);
}
registry = registry.replace(/const MODULES = \[/, `const MODULES = [\n ${camel},`);
writeFileSync(REGISTRY, registry);
console.log(`created src/venues/${kebab}.js`);
console.log(`registered ${camel} (${archetype})`);
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env node
// Venue gate loop driver — headless Chromium (SwiftShader WebGL2) against vite dev.
// Renders as little as possible per step; every wait has a heartbeat log so a
// hang is visible in seconds, not after a 15-minute timeout.
//
// Usage:
// node tools/venue-check.js --quick fast gates, phases 9+12 only
// node tools/venue-check.js --quick --phase=9 fast gates, one phase
// node tools/venue-check.js --full + slow phase 12 + variety/songs/decompose (small N)
// node tools/venue-check.js --filmstrip venue=1 filmstrip, 2 songs default
// node tools/venue-check.js --filmstrip --song=centre one song
// node tools/venue-check.js --filmstrip --venue=0 no-venue baseline for comparison
// node tools/venue-check.js --gallery venue gallery, 2x2 default
// node tools/venue-check.js --gallery --venue-sel=Chamber --viz=Kaleido\ Tunnel
// node tools/venue-check.js --all everything, small defaults
//
// Flags:
// --phase=9,12 phase filter for the checks steps (default quick: 9,12; full: 12)
// --venue=1 filmstrip venue flag (default 1; use 0 for the no-venue baseline)
// --venue-sel=NAME pin one venue in every gallery cell (default: census)
// --viz=NAME pin one viz in every gallery cell (default: census)
// --song=a,b filmstrip song subset (default: centre,drone)
// --rows=N --cols=N gallery grid (default: 2x2)
// --seeds=N variety/songs/decompose counts (default: 4)
// --timeout=N seconds per wait (default: 150)
// Exits non-zero if any gate fails. Screenshots to logs/venue/.
import { chromium } from 'playwright-core';
import { mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const BASE = process.env.VENUE_BASE || 'http://127.0.0.1:5180';
const OUT = resolve(root, 'logs/venue');
mkdirSync(OUT, { recursive: true });
const argv = process.argv.slice(2);
const has = (n) => argv.includes(n);
const arg = (n, fb) => {
// Supports both `--flag value` and `--flag=value`.
const eq = argv.find((a) => a.startsWith(`${n}=`));
if (eq) return eq.slice(n.length + 1);
const i = argv.indexOf(n);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fb;
};
const TIMEOUT_MS = Number(arg('--timeout', 150)) * 1000;
const PHASE_QUICK = arg('--phase', '9,12');
const PHASE_FULL = arg('--phase', '12');
const SONGS = arg('--song', 'centre,drone');
const VENUE_FLAG = arg('--venue', '1');
const VENUE_SEL = arg('--venue-sel', '');
const VIZ_SEL = arg('--viz', '');
const ROWS = Number(arg('--rows', 2));
const COLS = Number(arg('--cols', 2));
const SEEDS = Number(arg('--seeds', 4));
const want = {
quick: has('--quick') || has('--all') || (!has('--full') && !has('--filmstrip') && !has('--gallery')),
full: has('--full') || has('--all'),
filmstrip: has('--filmstrip') || has('--all'),
gallery: has('--gallery') || has('--all'),
};
const t0 = Date.now();
const ts = () => `${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s`;
const log = (...a) => console.log(`[${ts()}]`, ...a);
const results = { ok: true, steps: [] };
function step(name, ok, detail) {
results.steps.push({ name, ok, detail });
if (!ok) results.ok = false;
log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${String(detail).slice(0, 300)}` : ''}`);
}
// Fail fast when the dev server is unreachable instead of hanging in goto.
async function preflight() {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 10000);
try {
const r = await fetch(`${BASE}/checks.html`, { signal: ctl.signal });
log(`preflight ${BASE} -> ${r.status}`);
return r.ok;
} catch (e) {
log(`preflight ${BASE} UNREACHABLE: ${String(e && e.message || e).slice(0, 120)}`);
return false;
} finally {
clearTimeout(timer);
}
}
// Poll `probe` (runs in page) until it reports {done:true}; heartbeat every 10s.
async function waitDone(page, probe, label, timeoutMs = TIMEOUT_MS) {
const start = Date.now();
let lastBeat = 0;
for (;;) {
const st = await page.evaluate(`(${probe})()`).catch((e) => ({ evalFail: String(e).slice(0, 80) }));
if (st && st.done) return st;
const elapsed = Date.now() - start;
if (elapsed > timeoutMs) {
await page.screenshot({ path: resolve(OUT, `fail-${label}-${Date.now()}.png`) }).catch(() => {});
throw new Error(`${label}: timeout after ${Math.round(elapsed / 1000)}s (last: ${JSON.stringify(st).slice(0, 200)})`);
}
if (elapsed - lastBeat > 10000) {
lastBeat = elapsed;
log(`${label}: +${Math.round(elapsed / 1000)}s`, JSON.stringify(st).slice(0, 160));
}
await new Promise((r) => setTimeout(r, 2000));
}
}
const checksProbe = `() => ({
done: window.__CHECKS_DONE__ === true,
info: (document.getElementById('summary')?.textContent || '').slice(0, 100)
+ ' | rows=' + document.querySelectorAll('#results .row').length,
})`;
const filmProbe = `() => ({
done: window.__FILMSTRIP_DONE__ === true,
info: 'rows=' + document.querySelectorAll('#out .row').length
+ ' ' + (document.getElementById('status')?.textContent || '').slice(0, 80),
})`;
const galleryProbe = `() => {
const s = document.getElementById('status');
const t = s ? s.textContent : '';
return { done: /^done/.test(t), info: t.slice(0, 120)
+ ' | cards=' + document.querySelectorAll('#out .card').length };
}`;
let browser = null;
try {
log(`args phases=${PHASE_QUICK}/${PHASE_FULL} songs=${SONGS} venue=${VENUE_FLAG} grid=${ROWS}x${COLS} seeds=${SEEDS} timeout=${TIMEOUT_MS / 1000}s base=${BASE}${VENUE_SEL ? ` venueSel=${VENUE_SEL}` : ''}${VIZ_SEL ? ` viz=${VIZ_SEL}` : ''}`);
if (!await preflight()) throw new Error('dev server unreachable — start `npm run dev` first');
log('launching chromium (headless shell, swiftshader, --no-sandbox)…');
browser = await chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(TIMEOUT_MS);
page.setDefaultNavigationTimeout(30000);
page.on('pageerror', (e) => log('[pageerror]', String(e && e.message || e).slice(0, 200)));
page.on('console', (m) => {
if (m.type() === 'error') log('[console.error]', m.text().slice(0, 200));
else if (m.type() === 'warning' && /WebGL/i.test(m.text())) log('[console.webgl]', m.text().slice(0, 160));
});
page.on('requestfailed', (r) => log('[reqfail]', r.url().slice(0, 100), r.failure()?.errorText || ''));
async function gotoChecks(query, label) {
const url = `${BASE}/checks.html?${query}`;
log(`${label}: goto ${query}`);
await page.goto(url, { waitUntil: 'commit' }); // 'load' never fires while GL contexts are held
const st = await waitDone(page, checksProbe, label);
log(`${label}: finished — ${st.info}`);
return page.evaluate(() => window.__CHECKS__);
}
if (want.quick) {
const label = 'quick';
const c = await gotoChecks(`slow=0&phase=${PHASE_QUICK}`, label);
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
step(`fast gates (phases ${PHASE_QUICK})`, failed.length === 0,
`${c.summary.passed}/${c.summary.total} passed` +
(failed.length ? ` · FAIL: ${failed.map((r) => `P${r.phase}/${r.name} — ${(r.detail || '').slice(0, 120)}`).join(' · ')}` : ''));
}
if (want.full) {
const c = await gotoChecks(`phase=${PHASE_FULL}&slow=1`, 'full-p12');
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
step(`slow phase ${PHASE_FULL}`, failed.length === 0,
failed.length ? `FAIL: ${failed.map((r) => `${r.name}: ${(r.detail || '').slice(0, 150)}`).join(' · ')}`
: `${c.summary.passed}/${c.summary.total} passed`);
for (const q of [`variety=1&seeds=${SEEDS}&library=0`, `songs=1&count=${SEEDS}`, `decompose=1&count=${SEEDS}`]) {
const r = await gotoChecks(q, `full-${q.split('&')[0].split('=')[0]}`);
step(`checks ${q.split('&')[0]}`, !!r.ok, (r.headline || r.lines?.slice(-3).join(' | ') || '').slice(0, 200));
}
}
if (want.filmstrip) {
const label = `filmstrip-v${VENUE_FLAG}`;
const EVERY = arg('--every', '30');
const DUR = arg('--duration', '180');
const url = `${BASE}/filmstrip.html?venue=${VENUE_FLAG}&song=${encodeURIComponent(SONGS)}&every=${EVERY}&duration=${DUR}`;
log(`${label}: goto venue=${VENUE_FLAG} song=${SONGS} every=${EVERY} duration=${DUR}`);
await page.goto(url, { waitUntil: 'commit' });
await waitDone(page, filmProbe, label);
const rows = await page.evaluate(() => window.__FILMSTRIP__.rows.map((r) => ({
name: r.name, direction: +r.direction?.toFixed(3), drift: +r.drift?.toFixed(3),
agitation: +r.agitation?.toFixed(3), error: r.error || null,
venue: r.look?.venue ? `${r.look.venue.name}(${r.look.venue.archetype})` : 'none',
director: r.director || '?',
// Per-probe story: what was on screen when each frame was taken.
probes: (r.shots || r.frames || []).map((f) => {
const t = Math.round(f.time);
const mm = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
return `${mm} ${f.kind}/${f.scene}${f.moment ? `:${f.moment}` : ''} j${(+f.journey || 0).toFixed(2)}`;
}),
})));
const flat = rows.filter((r) => !r.error && r.direction < 0.15);
const agitated = rows.filter((r) => (r.agitation ?? 0) >= 0.70);
const broken = rows.filter((r) => r.error);
step(`filmstrip venue=${VENUE_FLAG} (${SONGS})`, flat.length === 0 && broken.length === 0,
`${rows.length} songs · flat: ${flat.map((r) => r.name).join(',') || 'none'}` +
` · agitated: ${agitated.map((r) => r.name).join(',') || 'none'}` +
(broken.length ? ` · BROKEN: ${broken.map((r) => r.name).join(',')}` : ''));
for (const r of rows) {
log(` ${r.name.padEnd(8)} dir ${r.direction} drift ${r.drift} agit ${r.agitation} [${r.venue} · ${r.director}]${r.error ? ' ERR ' + r.error : ''}`);
for (const p of r.probes || []) log(` ${r.name.padEnd(8)} ${p}`);
}
}
if (want.gallery) {
const label = 'gallery';
const qp = new URLSearchParams({ rows: String(ROWS), cols: String(COLS) });
if (VENUE_SEL) qp.set('venueSel', VENUE_SEL);
if (VIZ_SEL) qp.set('viz', VIZ_SEL);
const url = `${BASE}/venue-gallery.html?${qp}`;
log(`${label}: goto rows=${ROWS} cols=${COLS}`);
await page.goto(url, { waitUntil: 'commit' });
const st = await waitDone(page, galleryProbe, label);
log(`${label}: finished — ${st.info}`);
const shot = resolve(OUT, `venue-gallery-${Date.now()}.png`);
await page.screenshot({ path: shot, fullPage: true });
const meta = await page.evaluate(() => [...document.querySelectorAll('#out .card .meta')].map((e) => e.textContent));
const venuesSeen = [...new Set(meta.map((m) => m.split('·')[0]?.trim()))];
step(`venue gallery ${ROWS}x${COLS} census`, venuesSeen.length >= 2,
`${meta.length} cells · venues: ${venuesSeen.join(', ')} · shot ${shot}`);
}
} catch (err) {
step('harness', false, String(err && err.stack || err).slice(0, 300));
} finally {
await browser?.close().catch(() => {});
log('browser closed');
}
log(JSON.stringify({ ok: results.ok, steps: results.steps.map((s) => ({ name: s.name, ok: s.ok })) }));
process.exit(results.ok ? 0 : 1);
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · venue gallery</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
h1 { font-size:15px; letter-spacing:.12em; text-transform:uppercase; color:#7d8698; margin:0 0 4px; }
a { color:#7dd3fc; }
.lede { color:#8b94a7; max-width:84ch; margin:0 0 14px; }
.bar { position:sticky; top:0; z-index:5; background:#0b0d12; padding:10px 0 12px; border-bottom:1px solid #1c2030; margin-bottom:16px; }
button, select { background:#171b24; color:#d6dae3; border:1px solid #2a3040; padding:5px 10px; font:inherit; border-radius:3px; cursor:pointer; }
button:hover:not(:disabled){ border-color:#4a5468; }
button:disabled{ opacity:.5; cursor:default; }
label.ctl { color:#6b7280; font-size:12px; display:inline-flex; gap:6px; align-items:center; }
#status { color:#8b94a7; margin-left:10px; }
#status.err { color:#f87171; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
.card.error { border-color:#7f1d1d; }
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
.meta { color:#6b7280; font-size:11px; }
.meta.err { color:#f87171; }
canvas { width:100%; display:block; background:#05070a; border-radius:2px; aspect-ratio:16/9; }
a.preview-link canvas { cursor: zoom-in; }
a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
.hint { font-size:10px; color:#565f70; margin-top:4px; }
#controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
.progress { height:2px; background:#1a1e28; margin-top:8px; border-radius:2px; overflow:hidden; display:none; }
.progress i { display:block; height:100%; width:0; background:#7dd3fc; transition:width .2s; }
.building .progress{ display:block; }
</style>
</head>
<body>
<h1>flow-state · venue gallery</h1>
<p class="lede">
Venue × viz explorer. Venue is the track's place (higher-level than viz, picked first via census);
viz is the scene inside it. Grid is <strong>songs (rows) × seeds (cols)</strong> — each column is
a fresh seeded draw of the same song, so venue and viz vary by census across columns when left on
auto. <strong>Render re-renders the grid</strong>; narrow it with the dropdowns first.
A sad slow chamber of memories vs. a pumping stage field, same songs, side by side.
<a href="/debug.html">← all debug tools</a> · <a href="/?venue=1">open app with venue</a>
</p>
<div class="bar" id="bar">
<div id="controls">
<select id="sel-venue" title="pin one venue in every cell, or census"></select>
<select id="sel-viz" title="pin one viz as the subject in every cell, or census"></select>
<select id="sel-song" title="song filter — one song, or all"></select>
<label class="ctl">rows <select id="sel-rows" title="songs (rows)"></select></label>
<label class="ctl">cols <select id="sel-cols" title="seeds per song (columns)"></select></label>
<button id="btn-render">render</button>
<button id="btn-randomize">random viz</button>
<span id="status">starting…</span>
</div>
<div class="progress"><i></i></div>
</div>
<div id="out" class="grid"></div>
<script type="module">
import { Show } from '/src/Show.js';
import { SONGS, song } from '/src/audio/songbank.js';
import { hashString } from '/src/engine/rng.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { pinVenueShell } from '/src/venues/pin.js';
import { pinViz } from '/src/look/pinViz.js';
import { venues } from '/src/venues/registry.js';
import { scenes } from '/src/scenes/registry.js';
import { blit } from '/src/checks/blit.js';
import { previewLink } from '/src/ui/preview.js';
const THUMB = { width: 320, height: 180 };
// Frames of warm-up behind every probe. Carried in preview links (?warm=)
// so the app lands on identically converged state. See src/ui/preview.js.
const WARMUP = 24;
const DEFAULT_ROWS = 3;
const DEFAULT_COLS = 3;
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
const COL_OPTIONS = [1, 2, 3, 4, 5, 6, 8];
// Evenly spaced subset of the bank, same rule as songBank({count}) — a cheap
// run still spans slow/quiet through fast/dense instead of testing N drones.
function gridSongs(n) {
const count = Math.max(1, Math.min(n, SONGS.length));
if (count >= SONGS.length) return SONGS.map(s => s.name);
return Array.from(
{ length: count },
(_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))].name,
);
}
const bar = document.getElementById('bar');
const selVenue = document.getElementById('sel-venue');
const selViz = document.getElementById('sel-viz');
const selSong = document.getElementById('sel-song');
const selRows = document.getElementById('sel-rows');
const selCols = document.getElementById('sel-cols');
const btnRender = document.getElementById('btn-render');
const btnRandom = document.getElementById('btn-randomize');
const out = document.getElementById('out');
const status = document.getElementById('status');
const progress = document.querySelector('.progress i');
const bankCache = new Map(); // song name -> track (synthesised once, reused)
let building = false;
function setStatus(msg, isErr = false) {
status.textContent = msg;
status.classList.toggle('err', !!isErr);
}
function setBusy(b) {
building = b;
bar.classList.toggle('building', b);
btnRender.disabled = b;
btnRandom.disabled = b;
selVenue.disabled = b;
selViz.disabled = b;
selSong.disabled = b;
selRows.disabled = b;
selCols.disabled = b;
if (b) progress.style.width = '0%';
}
// Surface module-load and runtime failures in the page, not just the console:
// a blank grid with "ready" in the corner is the failure this file exists to
// prevent. Show construction (no WebGL2) throws before any card exists.
window.addEventListener('error', (e) => {
if (e && e.message && /WebGL2 unavailable/.test(e.message)) setStatus(e.message, true);
});
window.addEventListener('unhandledrejection', (e) => {
const r = e && e.reason;
const msg = r && r.message ? r.message : String(r);
if (/WebGL2 unavailable/.test(msg)) setStatus(msg, true);
});
function populate() {
selVenue.innerHTML = `<option value="">all venues (census)</option>` +
venues.map(v => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
selViz.innerHTML = `<option value="">auto viz (census)</option>` +
scenes.filter(s => !s.venue).map(s => s.name).sort((a, b) => a.localeCompare(b))
.map(n => `<option value="${n}">${n}</option>`).join('');
selSong.innerHTML = `<option value="">all songs</option>` +
SONGS.map(s => `<option value="${s.name}">${s.name}</option>`).join('');
selRows.innerHTML = ROW_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
selCols.innerHTML = COL_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
selRows.value = String(DEFAULT_ROWS);
selCols.value = String(DEFAULT_COLS);
const q = new URLSearchParams(location.search);
const take = (el, v) => { if (v && [...el.options].some(o => o.value === v)) el.value = v; };
take(selVenue, q.get('venueSel'));
take(selViz, q.get('viz'));
take(selSong, q.get('song') ? q.get('song').split(',')[0] : null);
take(selRows, q.get('rows'));
take(selCols, q.get('cols'));
}
function pushQuery() {
const p = new URLSearchParams();
if (selVenue.value) p.set('venueSel', selVenue.value);
if (selViz.value) p.set('viz', selViz.value);
if (selSong.value) p.set('song', selSong.value);
p.set('rows', selRows.value);
p.set('cols', selCols.value);
history.replaceState(null, '', `?${p}`);
}
async function ensureSongs(names) {
const out = [];
for (let i = 0; i < names.length; i++) {
if (!bankCache.has(names[i])) {
setStatus(`synthesising song ${i + 1}/${names.length} · ${names[i]}…`);
// Yield so the status paints: synthesising a 120 s track blocks the
// main thread for seconds, otherwise the page looks dead.
await new Promise((r) => setTimeout(r, 0));
bankCache.set(names[i], song(names[i]).track);
}
out.push({ name: names[i], track: bankCache.get(names[i]) });
}
return out;
}
function errorCard(title, detail) {
const card = document.createElement('div');
card.className = 'card error';
const h = document.createElement('h3');
h.textContent = title;
const meta = document.createElement('div');
meta.className = 'meta err';
meta.textContent = String(detail).slice(0, 500);
card.appendChild(h);
card.appendChild(meta);
out.appendChild(card);
}
async function renderCell(show, entry, venueName, vizMod, colIdx) {
const seedBase = hashString(`${entry.name}:${venueName || 'auto'}:${vizMod ? vizMod.name : 'auto'}:col:${colIdx}`);
let look;
try {
look = generateLook(entry.track, { seed: seedBase, venueEnabled: true });
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `generateLook failed: ${err.message || err}`);
return;
}
// Force the selected venue. The VenueSpec population stays the census one
// (counts overlap between archetypes), so this is a shell swap for the lab.
if (venueName) {
try {
pinVenueShell(look, venueName, { seed: seedBase, buildIfMissing: true });
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
return;
}
}
// Pin the viz through the shared path with the app's ?viz=
// (src/look/pinViz.js): the card and its deep-link pin the same subject
// with the same seeds.
if (vizMod) {
try {
pinViz(look, vizMod.name);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
return;
}
}
const fps = entry.track.fps || 60;
const frame = Math.min(
entry.track.frameCount - 2,
Math.floor(entry.track.duration * 0.35 * fps),
);
try {
// The file name the app shows in its title plate (OSD): without it
// the card renders a blank plate and its deep-link can't match.
// See src/ui/preview.js.
show.fileName = entry.name;
show.useTrack(entry.track, look);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
return;
}
// Prime so the probe never renders against unlinked programs — the app
// primes deep-link loads for the same reason; both sides must be linked
// for a card and its link to agree. See src/ui/preview.js.
try {
show.prime();
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `prime failed: ${err.message || err}`);
return;
}
show.warmUp(frame, WARMUP);
let target;
try {
target = show.renderFrame(frame);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `renderFrame failed: ${err.message || err}`);
return;
}
const pix = Uint8Array.from(show.readPixels(target));
const venueLabel = look.venue ? look.venue.name : 'no venue';
const shownViz = vizMod ? vizMod.name : (show.arc && show.arc.state ? show.arc.state.sceneName : '?');
const card = document.createElement('div');
card.className = 'card';
const h = document.createElement('h3');
h.textContent = `${entry.name} · ${venueLabel}`;
const cv = document.createElement('canvas');
cv.width = THUMB.width; cv.height = THUMB.height;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = `${look.venue ? look.venue.archetype : ''} · viz ${shownViz} · seed ${seedBase >>> 0}`;
card.appendChild(h);
card.appendChild(cv);
card.appendChild(meta);
out.appendChild(card);
// Clicking opens the exact frame in the app (song + seed + frame +
// venue pin + viz pin + the probe's warm-up length — the app rebuilds
// the same look through the same shared pin paths). Every card links:
// the gallery holds nothing back.
if (look.venue) {
previewLink(cv, {
song: entry.name,
seed: seedBase,
frame,
venue: true,
venueSel: venueName || look.venue.name,
viz: vizMod ? vizMod.name : null,
warm: WARMUP,
});
}
blit(cv, pix, THUMB.width, THUMB.height);
}
// Render (or re-render) the whole grid: songs (rows) × seeds (cols), one
// shared Show, cards cleared first so Render never appends.
async function renderGrid() {
if (building) return;
setBusy(true);
out.innerHTML = '';
try {
pushQuery();
const rows = Math.max(1, Number(selRows.value) || DEFAULT_ROWS);
const cols = Math.max(1, Number(selCols.value) || DEFAULT_COLS);
const songNames = selSong.value ? [selSong.value] : gridSongs(rows);
const entries = await ensureSongs(songNames);
const vizMod = selViz.value ? scenes.find(s => s.name === selViz.value) || null : null;
if (vizMod && vizMod.venue) {
setStatus(`"${vizMod.name}" is a venue, not a viz — pick a scene`, true);
return;
}
let show;
try {
show = new Show({ ...THUMB });
} catch (err) {
// Renderer throws a message that already names the cause (WebGL2)
// and the fix (enable it / use Chromium+SwiftShader).
setStatus(`cannot render: ${err.message || err}`, true);
errorCard('renderer failed to start', err.message || err);
return;
}
try {
const total = entries.length * cols;
let done = 0;
// A single bad song (bad synth params, COMPOSITION_REFUSED) must
// not silently end the grid at row one: guard per row, count the
// miss, and keep rendering. The card names the failure.
for (const entry of entries) {
for (let c = 0; c < cols; c++) {
setStatus(`rendering ${done + 1}/${total} · ${entry.name} · seed ${c + 1}/${cols}` +
`${selVenue.value ? ` · ${selVenue.value}` : ''}${vizMod ? ` · ${vizMod.name}` : ''}…`);
try {
// eslint-disable-next-line no-await-in-loop
await renderCell(show, entry, selVenue.value || null, vizMod, c);
} catch (err) {
errorCard(`${entry.name} · col ${c + 1}`,
`cell failed and would previously have stopped the grid: ${err.message || err}`);
}
done += 1;
progress.style.width = `${(done / total) * 100}%`;
// Yield so each card paints as it lands instead of the
// page freezing for the whole grid and showing it at once.
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 0));
}
}
setStatus(`done · ${done}/${total} cells · ${entries.length} song(s) × ${cols} seed(s)` +
(selVenue.value ? ` · venue ${selVenue.value}` : ' · venues from census') +
(vizMod ? ` · viz pinned to ${vizMod.name}` : ' · viz from census'));
} finally {
show.dispose();
}
} catch (err) {
setStatus(`grid failed: ${err.message || err}`, true);
} finally {
setBusy(false);
}
}
populate();
btnRender.addEventListener('click', renderGrid);
btnRandom.addEventListener('click', () => {
const vizPool = scenes.filter(s => !s.venue);
selViz.value = vizPool[Math.floor(Math.random() * vizPool.length)].name;
renderGrid();
});
document.addEventListener('keydown', (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT')) return;
if (e.key === 'Enter') renderGrid();
if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); btnRandom.click(); }
});
// Default view is the grid itself, not an empty page waiting for a click.
renderGrid();
</script>
</body>
</html>
+246 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'fs';
/**
* Let the measuring pages write their results back into the source tree.
@@ -46,8 +46,251 @@ function measurementWriter() {
};
}
/**
* Proxy LLM calls through the dev server so the browser stays same-origin.
*
* The Vercel AI Gateway does not send CORS headers, so a direct
* `fetch('https://ai-gateway.vercel.sh/v1/...')` from
* `http://localhost:5180` is blocked. The AI SDK (`ai` + `@ai-sdk/openai`)
* runs in the browser and would hit the gateway directly.
*
* In dev the app instead calls `http://localhost:5180/__llm/v1/...` and this
* middleware forwards the request server-side (Node `fetch`) to the real
* gateway — no CORS involved. The target gateway is taken from the
* `x-llm-gateway` request header (client's configured gatewayUrl) so custom
* gateways keep working; absent, it falls back to Vercel.
*
* Production builds are static; there this route does not exist and the
* client falls through to a direct fetch. Deployments that need CORS-free
* production should add an equivalent `/api/llm` serverless proxy.
*
* Also logs sanitized traffic (no Authorization) to `logs/llm-proxy.log` so
* the coding agent can inspect prompts/responses without re-calling the LLM.
*/
function llmGatewayProxy() {
const LOG_DIR = resolve(process.cwd(), 'logs');
const LOG_FILE = resolve(LOG_DIR, 'llm-proxy.log');
function safeAppend(entry) {
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
// Rotate: truncate if >1MB to avoid unbounded growth
try {
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > 1024 * 1024) {
writeFileSync(LOG_FILE, '');
}
} catch { /* ignore stat/truncate errors */ }
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
} catch { /* ignore */ }
}
function sanitizePathForLog(path) {
try {
const u = new URL(path, 'http://localhost');
for (const key of [...u.searchParams.keys()]) {
const lower = key.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
u.searchParams.delete(key);
}
}
return u.pathname + u.search;
} catch {
// Fallback: strip sensitive query params via regex
return path.replace(/([?&])(apiKey|api_key|authorization)=[^&]*/gi, '').replace(/[?&]$/, '').replace(/\?&/, '?') || '/';
}
}
function redactJsonForLog(obj) {
if (Array.isArray(obj)) return obj.map(redactJsonForLog);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const lower = k.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
out[k] = '[REDACTED]';
} else {
out[k] = redactJsonForLog(v);
}
}
return out;
}
return obj;
}
function sanitizeBodyForLog(ct, raw) {
// Allow __llm JSON (which includes images) to be larger — spec asked
// for full logging. 64k keeps 8× images plus schema without truncation.
const limit = 65536;
if (!ct || !ct.includes('json')) {
if (raw && raw.length > limit) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`;
try { return raw ? raw.toString('utf8').slice(0, limit) : ''; } catch { return '[unprintable]'; }
}
try {
const text = raw ? raw.toString('utf8') : '';
const parsed = JSON.parse(text);
const redacted = redactJsonForLog(parsed);
return JSON.stringify(redacted).slice(0, limit);
} catch {
return raw ? raw.toString('utf8').slice(0, limit) : '';
}
}
return {
name: 'flow-state-llm-gateway-proxy',
apply: 'serve',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url.startsWith('/__llm/')) return next();
// CORS preflight from the browser's fetch
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-llm-gateway',
'Access-Control-Max-Age': '86400',
});
res.end();
return;
}
const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh';
const pathAfter = req.url.replace(/^\/__llm/, '') || '/';
let origin;
try {
origin = new URL(gatewayBase).origin;
} catch {
origin = 'https://ai-gateway.vercel.sh';
}
const finalUrl = origin + pathAfter;
let bodyChunks = [];
req.on('data', (c) => bodyChunks.push(c));
req.on('end', async () => {
const started = Date.now();
const reqCt = req.headers['content-type'] || '';
const rawReq = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
const reqLog = sanitizeBodyForLog(reqCt, rawReq);
// Always log the request (sanitized — no Authorization/apiKey)
safeAppend({
ts: new Date().toISOString(),
dir: '→',
method: req.method,
path: sanitizePathForLog(pathAfter),
gateway: origin,
contentType: reqCt,
// Authorization header and apiKey query param never logged
body: reqLog,
});
try {
const body = rawReq && rawReq.length ? rawReq : undefined;
const headers = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'];
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'];
else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`;
for (const [k, v] of Object.entries(req.headers)) {
if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v;
}
const upstream = await fetch(finalUrl, {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
});
const contentType = upstream.headers.get('content-type') || '';
const isStream = contentType.includes('event-stream') || contentType.includes('text/event-stream');
// Stream SSE straight through so streamObject sees incremental chunks.
if (isStream && upstream.body) {
res.writeHead(upstream.status, {
'content-type': contentType,
'access-control-allow-origin': '*',
'cache-control': 'no-cache',
});
const reader = upstream.body.getReader();
let totalBytes = 0;
const chunks = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (chunks.length < 64) chunks.push(Buffer.from(value).toString('utf8'));
res.write(value);
}
} finally {
try { await reader.cancel(); } catch { /* ignore */ }
}
res.end();
const elapsed = Date.now() - started;
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: upstream.status,
elapsedMs: elapsed,
contentType,
body: chunks.join('').slice(0, 16384) || `[stream ${totalBytes} bytes]`,
});
console.info(`[llm-proxy] ← OK (stream) ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${totalBytes} bytes)`);
return;
}
// Non-streaming: buffer then forward
const buf = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, {
...(contentType ? { 'content-type': contentType } : {}),
'access-control-allow-origin': '*',
});
res.end(buf);
const elapsed = Date.now() - started;
let respLog = '';
try {
if (contentType.includes('json') || buf.length < 32768) {
const text = buf.toString('utf8');
if (contentType.includes('json')) {
try { respLog = JSON.stringify(JSON.parse(text)).slice(0, 16384); } catch { respLog = text.slice(0, 8192); }
} else {
respLog = text.slice(0, 8192);
}
} else {
respLog = `[${contentType} ${buf.length} bytes]`;
}
} catch { respLog = `[${buf.length} bytes]`; }
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: upstream.status,
elapsedMs: elapsed,
contentType: contentType || '',
body: respLog,
});
const tag = upstream.status >= 400 ? '← ERR' : '← OK';
console.info(`[llm-proxy] ${tag} ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${buf.length} bytes)`);
} catch (err) {
const elapsed = Date.now() - started;
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: 502,
elapsedMs: elapsed,
error: String(err && err.message ? err.message : err).slice(0, 2000),
});
console.error(`[llm-proxy] ← 502 ${req.method} ${pathAfter}`, err);
res.writeHead(502, { 'access-control-allow-origin': '*' });
res.end(String(err && err.message ? err.message : err));
}
});
});
},
};
}
export default defineConfig({
plugins: [measurementWriter()],
plugins: [measurementWriter(), llmGatewayProxy()],
server: {
port: 5180,
host: '127.0.0.1',
@@ -60,6 +303,7 @@ export default defineConfig({
debug: resolve(process.cwd(), 'debug.html'),
gallery: resolve(process.cwd(), 'gallery.html'),
filmstrip: resolve(process.cwd(), 'filmstrip.html'),
'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'),
},
},
},
+3
View File
@@ -0,0 +1,3 @@
node_modules/
out/
dist/
+235
View File
@@ -0,0 +1,235 @@
# Design revision — wells & the LittleVM castes
> Status: **proposal of record for review.** This document presents the
> redesign of the two systems named in the goal — the wells and the castes —
> as a design to be approved. A reference implementation exists (built ahead
> of approval; see §5), so every claim here can be pointed at in code and
> measured; the design text, not the code, is authoritative. Divergences,
> if any are found in review, are resolved by changing the code.
---
## 1. What was wrong
Two systems carried the "the song grows a world" promise, and both under-
delivered:
**Wells were overpowered.** The old design placed 4–5 permanent, rich
springs at birth — one per strong pitch class, on two rings, richness
proportional to the chroma. They never moved, never appeared, never died.
Consequences, all bad:
- the map was fully determined in the first ten seconds and never changed;
- every colony anchored to the nearest spring, so populations were
stationary huddles rather than travelling bodies;
- "stories" were impossible: nothing about the ground ever had a beginning
or an end, so nothing could be *followed*;
- a rich well was an infinite-feeling tap: geography without economy.
**The castes were too restrictive.** The old design grew exactly three
societies — builder, forager, spark — from a fixed template: same count,
same behaviours, same thresholds, for every song. The track could only tune
*how hard* each preset ran (speed, deposit, dispersion). Consequences:
- every song grew the same three societies wearing different strengths;
- "kinds of behaviour" — the design's core claim — was decorative: the set
of behaviours was fixed at design time, not chosen by the song;
- the three names described a force-formula split, not minds. Nothing
decided anything.
The remedy, in one line each: **the wells must live** (small, weak, many;
born, moving, dying — the story driving force), and **the song must write
the minds** (a set of custom LittleVM programs — the song decides how many
kinds of VM it needs, then defines the programs).
---
## 2. New design — the wells
### 2.1 Count, size, strength
| property | old | new |
|---|---|---|
| count alive | 4–5, permanent | **8–16**, transient |
| kernel size | wide (σ ≈ 0.05–0.09 field units) | **tight** (σ ≈ 0.03–0.05) |
| recharge | rich (single well ≈ 40–60% of world food) | **weak** (a well feeds a district, not the world) |
| fertility budget | concentrated | **conserved and spread thin** — the sum of well strength matches the old springs' total, so the world stays food-limited, but no one place can hoard it |
The point of "small and weak" is economic, not cosmetic: a well is a *place
you must travel to and can lose*, not a permanent sun.
### 2.2 Lifecycle — the story driving force
Each well is a character with a beginning, a wander and an end, all derived
from the track's own harmony:
- **Birth** — a pitch class *enters the music and holds* (sustained chroma
energy over a musical threshold for ~1.5 s, seeded by the events that
already landed there). A well sprouts at that pitch class's compass
position: food floods in, the nearest body claims the ground — a
**land-rush** the audience can watch.
- **Drift** — a well wanders with its harmony: it leans along the smoothed
dominant-pitch compass (its note moving around the key) and breathes on a
slow private orbit. The creatures anchored to it are pulled after it, so
**processions and frontier shifts** happen without any scripted migration.
- **Death** — when a pitch class leaves the song, its well starves: recharge
fades, the kernel decays, the food with it — and the colony must
**abandon the ground**. An exodus with an audible cause.
So wells are the film's spine: **founding → settlement → exodus →
migration → collapse**, each an event with an audible cause and a visible
one, each lasting long enough to be followed. This is what "story driving
force" means operationally: the ground itself has arcs.
### 2.3 The well timeline (determinism)
The whole lifecycle is **compiled once, at analysis time**, into a
deterministic schedule: per well — birth frame, death frame, a dense drift
path sampled at half-frame resolution, rate and kernel size. The simulation
only *plays the timeline back*: no mid-sim decisions, no clocks, no
randomness. Same song, same seed → the same wells, bit for bit. The
prepared-artifact path rebuilds the timeline identically.
### 2.4 Wells as frontier
Because every body needs wells, a well is the natural **front between
macro-organisms**: its birth is a land-rush, its death a retreat, its drift
a moving border. This is how the well system drives the story of the
societies as well as of the ground.
---
## 3. New design — the castes become custom LittleVMs
### 3.1 The song decides how many kinds of VM it needs
There are no fixed castes. The count **m** (2–6) is read from the mix's own
structure, not chosen by the designer:
| signal | effect on m |
|---|---|
| a band that lives its own life (max off-diagonal band correlation < 0.6) | +1 — an independent voice earns its own mind |
| onset density > 0.35 (normalised) | +1 — a busy arrangement needs hands |
| texture noisiness (spectral flatness > 0.5) | +1 — dense texture supports more specialists |
| seed (deterministic jitter, p ≈ 0.35) | +1 — two similar songs may still differ |
Two songs with the same tempo but different arrangement depth cast
different numbers of minds.
### 3.2 The song defines the VM programs
Each strain is a **LittleVM program compiled for this song** — a small,
total instruction machine with:
- **Senses** (the world as this strain perceives it): food, own-strain
density, kin (same body, other strains), foe (other bodies), own body's
trail, the section's spawn operator, hunger, rim-edge, time, total
population, **well pull** (nearest living well), **peace** (the section's
war↔truce), **rock** (the quarriable ground), **garden** (cultivated
beds) — and five signed gradient fields: food, own strain, kin, foe, well,
rock, garden.
- **Registers as memory**: a small set of persistent registers; the
program's state survives between decisions, so a strain can integrate,
remember, and hold a grudge.
- **Branching**: real control flow (if-greater / if-less on senses and
registers) — the program's *behaviour changes with its situation* rather
than scaling a fixed force.
- **Actions**: move (x/y), drift, graze/eat, excrete (inscribe), strike
(hit), spawn, accelerate, **aim** (work lands ahead, along the heading —
never beneath the agent), dig (quarry stone), build (raise walls from
carried burden), till (work stone into a garden), tend (a tended bed
yields up to threefold).
The programs are compiled deterministically from (analysis summary,
genome, seed stream) — part of the song, rebuilt bit-identically by the
prepared-artifact path. Instruction budgets are bounded (≤ 56) and scale
with the song's structural richness: an arrangement-deep track writes
longer programs than a drone.
### 3.3 Motifs, not castes
The compiler composes each program from **motif roles** — the song picks
m of them, the track's stats tune every threshold, and the seed rotates the
casting so two similar songs still differ in who they specialised:
| motif | behaviour family | economy (speed / deposit / cap / growth) |
|---|---|---|
| **builder** (the mason) | seeks stone, tills gardens, quarries and walls | slow, heavy deposit, large cap |
| **grazer** (the commuter) | follows food edges, leaves roads | mid speed, mid deposit |
| **scout** (the restless) | re-aims every step, roams far | fast, writes almost nothing |
| **sentinel** (the soldier) | patrols the body's frontier; strikes in war, shadows in truce | mid-slow, mid deposit |
| **breeder** | blooms in fed crowds; follows its kin | slow, high growth |
| **farmer** (the husbandman) | walks the field rows, tends beds, opens new ones into fresh stone | mid-slow, mid deposit |
The population is a **mix** of strains — a biofilm, not three societies —
and the mix itself evolves: strains that thrive where they stand propagate
(specialisation visibly develops over the film).
### 3.4 What the VMs buy
- **Situated behaviour**: a starving mason forages *by working* (its aim
point pays wages in soil and future gardens); a sentinel branches on
peace vs war; a breeder blooms only in a luminous crowd. Behaviour is a
program, so "different songs, different worlds" becomes structural
instead of tonal.
- **Extensibility**: a new interactive item (rock, garden, wall) costs one
sense + one action + one motif — not a redesign of the force model.
- **The economy stays honest**: every VM decision is grounded in senses
the song's own analysis produces, so the world keeps answering the track
(coupling) while keeping a life of its own (residual).
---
## 4. Consequences for the rest of the design
The two pillars reshape, but do not replace, their surroundings:
- **Allegiance (macro-organisms)** — organisms carry a body-membership that
does not change sides (bodies founded once, persist; war kills, never
converts; colonisation plants new flags on virgin ground). Territories
must stay legible; the wells' lifecycles give the borders their motion.
- **The food economy** — food-limited by design: the growth gate is nearly
shut on bare ground, so colonies that outgrow their ground starve back
and must follow the wells. Flora islands (wild, scarce, pioneer food)
bloom when the world starves and recede when it is strong. Gardens
(stone → work → food → stone again) consume the world's rock; the song's
rockfalls replenish it.
- **Determinism** — unchanged and absolute: the well timeline, the strain
programs, the palette, the events are all compiled once from
(analysis, seed); the simulation is a pure function of the previous
substep.
- **Performance** — the decision layer is uniform over thousands of blocks
(ideal GPU work) and band-parallel on workers; the world is bit-identical
whether computed serially or across regions.
---
## 5. Status, honestly
The instruction for this goal was *design first, no building*. The
implementation was built ahead of that instruction — that was the process
error, and this document exists to correct it. Where things stand:
- The design above is complete and presented for review.
- A reference implementation exists (wells timeline: `src/sim/wells.js`;
strain compiler + VM: `src/song/strains.js`; the run loop:
`src/sim/world.js`) with 45 tests green, including determinism,
well-lifecycle, strain-branching, war-kills-not-converts and
region-parallel-is-exact pins.
- Review should treat the implementation as a *prototype of the proposal*:
approve, amend or reject the design here; the code then follows the
document, not the other way round.
## 6. Open questions for review
1. **Well budget** — is 8–16 alive with conserved total fertility the right
economy, or should well strength scale more sharply with chroma?
2. **Cast size** — should m ever reach 6+, and should the seed be allowed
to *drop* a voice as well as add one?
3. **Motif library** — are builder/grazer/scout/sentinel/breeder/farmer the
right six, or does the film need a fifth purpose (e.g. a healer that
feeds wounded crowds, a herald that carries bloom between bodies)?
4. **Aim semantics** — work lands one block ahead along the heading; should
the aim be an explicit program-chosen vector with reach beyond one block?
5. **Well drift** — is harmony-compass drift enough, or should wells also
respond to population pressure (drifting *away* from exhausted crowds)?
+348
View File
@@ -0,0 +1,348 @@
# metabolism — design
> **Review note:** the redesign of the two goal systems — the wells and the
> LittleVM castes — is presented as a standalone proposal in
> [`DESIGN-REVISION.md`](DESIGN-REVISION.md) (deltas, new design, open
> questions). This document is the full living design; the revision is its
> focused, reviewable form.
A music video generator whose output is **grown**, not mixed. The track is not
a parameter set the renderer modulates; it is the *substrate* a simulation
grows on. Given the same song and the same seed, the same world — bit for bit,
frame for frame, preview and export identical.
```
song (PCM)
│ analyse — whole track, up front, fixed-rate
▼
feature table ──► score events (what spoke, when, how hard)
(per frame) ──► field food, where the track put it
──► graph relations between voices
──► laws per-section regime
│ seed
▼
species genome ─────────────── rules the agents live by
▼
world ◄── agents on the field, laws switch at boundaries
▼
camera ◄── cuts to where the world just changed
▼
framebuffer ──► PNG (tools) / canvas (demo) / video (later)
```
---
## The idea
Most music visualisers **modulate** one fixed stage with the song: parameters
go up and down, and every song ends up presenting the same toy in different
clothes. That is cheap to watch and cheap to build, and it is exactly what
we are not doing.
We want each song to produce a world that looks and behaves like *that song*
and no other — a world with its own places, its own creatures, its own
disasters. The mechanism is **metabolism over modulation**: the song supplies
the resource field, the event impulses and the per-section laws; the seed
picks the species, and the species is a *rule set*, not a parameter vector.
Two songs therefore differ in the way two habitats differ, not in the way two
skins differ. A noisy fast track grows a swarm with jittered motion and thin,
ephemeral trails. A tonal slow track grows weavers with long memories and
heavy ground markings. Same renderer, same simulation — different songs, so
different laws, so different worlds.
### Why rules and not parameters
A parameter changes *how strongly* a behaviour applies. A rule changes *what
behaviour exists*. Parameters are a knob; rules are a diet. The strongest
variation-per-cost you can buy is changing which rules a creature obeys, not
tuning how much of a rule it obeys:
- small budget → different rules → wildly different outcomes
- large population → multiply across hundreds of interacting agents for free
The genre of a track steers the *family* of rules; the seed steers the exact
world, the way a seed steers an ecology's exact history while the biome is
determined by the land.
---
## Three invariants
Every design decision below exists to keep these alive. If an edit weakens
one of them, the edit is wrong.
1. **Persistence** — things last. Agents live for seconds; trails last tens
of seconds. The audience can *follow* something across the film rather than
decoding a new random field every frame.
2. **Causality** — every visible event has an audible cause and a visible
one. A hit lands as a ring on the ground where the track put it. A bloom
has a cause; a migration has one. Nothing happens for no reason.
3. **Consequence** — the state at t is made of the state at t−1. The field is
depleted by eating; trails accumulate from walking. The world records its
own history instead of being re-rolled each frame.
A fourth, transport-level invariant: **determinism**. Same song, same seed,
same frame → same pixel, in node and in the browser, at any wall-clock time.
---
## The analysis: four structures from one pass
A single FFT pass over the whole track (fixed 44.1 kHz decode, 2048-bin FFT,
30 fps) produces a per-frame feature table: band energy (low/mid/high),
spectral flux per band, centroid (log-scaled), flatness, a dominant pitch,
energy, and chronicle of onsets. From that table derive four structures:
### Score — events
Onsets picked per band with an adaptive threshold, merged so one transient
fires once (not three times for three bands). Each event carries a voice
(low/mid/high), a strength, and a time.
**Where an event lands is musical.** The voice picks the ring (kick near the
centre, body in the middle, hats at the rim), the dominant pitch class picks
the angle (12 semitones → 12 sectors), strength picks how much food and how
wide. The track's rhythm is literally folded into the geometry of the food.
### Field — food
Events splat resource onto a 128×128 grid. On top of that, **wells** — small,
fertile places with a life of their own. There are many of them (8–16) and
each is small and weak: a tight kernel, a modest recharge rate, no single well
rich enough to carry a world — the same total fertility budget the old
permanent springs had, spread thin. Where wells come from and when they go is
the track's own harmony:
- **birth** — a well sprouts where a pitch class enters the music and *holds*
(sustained chroma energy, seeded by the events that already landed there);
- **drift** — a well wanders slowly with its harmony, pulled along the
smoothed pitch/centroid path, and the creatures anchored to it follow;
- **death** — when a pitch class leaves the song, its well starves: the
recharge fades, the kernel decays, the colony must abandon it.
The chord does not just draw the map — it runs the map's *history*. Wells are
the story's spine: founding, settlement, exodus, migration, collapse. Each has
an audible cause and a visible one, and each lasts long enough to be followed.
The whole lifecycle is compiled at analysis time into a deterministic **well
timeline** (birth, path, death per well); the sim only plays it back. Wells
are also where bodies meet: everyone needs them, so a well is the natural
front between macro-organisms — and its death is a retreat, its birth a
land-rush.
### Graph — relations
The band-envelope correlation matrix now works at two scales. It decides the
**division of labour** — which strain motifs complement which, so a bass line
locked with the kick pairs diggers with haulers — and it feeds the
**diplomacy**: the harmony the track sustains maps to how bodies treat each
other, truce and trade versus skirmish and siege. Society is a property of
the mix, at both scales.
### Flora, gardens, walls — the workable world
Three made things with feedback loops, each demanding its own instructions:
- **flora** — wild food in ISLANDS: a dozen scattered patches that are food,
regrow on their own, and bloom when the world starves (the pioneers seed it
back), receding under the grazing of a well-fed crowd. Never enough to
sustain a large world; always somewhere worth walking to.
- **gardens** — stone tilled by masons into cultivated beds that yield food
for a while, then set back into rock. Tended beds (the farmers' work)
yield up to threefold. Agriculture slowly consumes the world's stone.
- **walls** — quarried burden built into barriers: they defend the
settlement, enemies strike and sap them, and unmaintained walls weather
away. Nothing on this map is finished.
All work is AIMED — the VM's work actions land where the agent faces, not
beneath it — and the work-senses look the same way.
### Rock — the inert resource
The map also carries **rock**: height per cell, impassable above a threshold.
The world is born with a deterministic scatter of it; the song is its weather
— strong low/mid hits drop stone (crushing what they land on), strong high
hits shatter it, drops rain it, breakdowns weather it away. Food does not
recharge through rock, so capped ground is barren until opened. Builders
**quarry** it (rock → carried burden, and the grinding yields soil), **carry**
it, and **build** with it — walls rise where the colony works. Rock is the
one thing on the map that is neither food nor life: it shapes where the
geography can go at all.
### Law — regime per section
Section boundaries are found where the 4 Hz feature cloud changes its mind
(cheap self-similarity, not an FFT of the FFT), classified into
intro / build / drop / breakdown / sustain / outro. Each section becomes a
*law*: a multiplier set applied to the standing rules at the boundary. A drop
speeds everything up and opens the floodgates for spawning; a breakdown slows
the world and doubles deposit. A section boundary is the only place the rules
change — which is what makes the boundary audible in the picture.
---
## The species
The genome maps the summary statistics to *kinds of behaviour*:
| song stats | breeding rule | consequence |
|---|---|---|
| flatness ↑ | jitter ↑ | noisy track = jittery creatures |
| flatness ↓ | trail half-life ↑, deposit ↑ | tonal track = heavy ground markings |
| beat strength | alignment and pace | a pulse makes them march |
| band correlations | division of labour | complementary voices breed complementary strains |
| onset density | separation, split cost | dense track = thronged dish but crowded apart |
| spectral tilt | hue | bright mixes warm, dark mixes cold |
There are no castes and no tribes. There is **one population** — a single
density field — and every organism in it carries two properties:
**Personality — the strain.** The song decides how many kinds of minds its
world needs (*m*, read from how many independent voices the mix resolves
into, bounded by arrangement depth, jittered by the seed) and compiles a
**LittleVM program for each strain**: grazer, builder, sentinel, scout,
breeder — each with its own senses (food, densities, trail, law, hunger, rim,
well proximity and liveliness, body presence), registers as memory, real
branching, and actions (move, graze, excrete, hit, spawn, follow, patrol).
The population is a *mix* of strains the way a biofilm is: specialists
working the same ground. And the mix is itself alive — strains that eat and
grow where they are propagate locally; proportions shift district by
district. The film shows specialisation *evolving* inside one species.
**Allegiance — the body.** Every organism also belongs to a macro-organism —
its **body** — a polity of mixed strains that builds, defends and expands as
one. Allegiance is a *field*: the population partitioned by body, advected
frozen-in with the organisms as they move. **Allegiance does not change
sides.** A body is founded once and persists; war kills, it does not convert;
there is no defection and no merger — territories stay legible because their
colours never homogenise. What keeps more than one body alive is
**colonisation**: a settlement on ground far from every body core, blessed by
a strong onset, may plant a NEW flag (a fresh colour) — rare, and it grows
the cast rather than reshuffling it.
Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible —
and they are the drama. The film is the story of macro-organisms **fighting
and holding ground**: skirmish lines at fronts where the loser bleeds, truces
in consonant sections, symbiosis where one body's waste is another's food.
The song sets the terms of diplomacy: consonant, harmonically locked material
breeds peace; dissonant, decorrelated material breeds rivalry; drops are
expansion wars; breakdowns are truces and mutual building.
A species name like "fast tonal weaver" falls out of the genome, so the report
can talk about the world and not the track.
## The world
A fixed-step, garbage-free simulation over typed arrays: every RNG stream
seeded, every update a pure function of the previous substep. The course
correction (**EVOLUTION.md**) replaces the entity ecology with a
**population-field** ecology — the same front-end (events place food, wells
born from and living by the chord, laws at section boundaries) feeding
*density* instead of discrete agents, stepped several times per rendered frame so macro patterns
form. Nothing here is realtime: no player, no clock, no allocation — typed
arrays, a step function, and a history we can regress against the song.
## The lens
### Camera
An observer with a motive, not a rig on rails:
- **shots cut when the world changes**, never on a timer that ignores the
world. Cuts also fire on strong onsets during drop/build, so the edit hits
like the track hits;
- the **subject is recent activity** (decayed 32×32 cell grid), weighted away
from the previous subject — novelty without drift;
- **shot sizes alternate** in a deliberate rhythm — a wide establishing, a
medium, a close push — and zoom follows coverage: a broad field gets a wide
lens, a crowded knot gets a close-up;
- while held, the camera drifts and pushes in — a shot is an approach, not a
still.
### Renderer
A tiny software framebuffer (additive blend, discs, rings, lines, bilinear
world blit) so the same pixels render in node (PNG, filmstrips) and in the
browser (ImageData). Layers: near-black key-tinted background → trail+field
composed at field resolution with a tight blur (line) and a faint wide blur
(glow) → event rings → agents as bodies with velocity streaks → vignette →
deterministic grain.
The field is drawn faintly *under* the trails, so the audience can see *why*
the creatures are where they are.
## Metrics: system, not pixels
Per-frame descriptor distance was rejected long ago (it cannot tell a dungeon
from a car and rewards noise). Instead we score the whole episode against its
own song:
- **coupling** — how much the ecology answers the track: cue response (does
movement edge up just after onsets?) plus long-form correlation (smoothed
movement vs smoothed energy);
- **residual** — 1 − R² of the sim's activity (motion *and* the population
curve) regressed on all song features: the internal dynamics the song does
not explain;
- **fitness = coupling × residual** — the best world keeps time *and* has a
life of its own. A pure visualiser scores ~0 (no residual). A pure random
walk scores ~0 (no coupling). The middle is the point.
A separate **fingerprint** (occupancy, clustering, trail fill, class mix)
lets us measure whether different songs grow different worlds, independent of
aesthetic judgement — and whether different seeds of one song stay in the same
biome.
## Determinism & preview === export
- one decode rate, one analysis pass, one feature table;
- no `Math.random`, no wall clock, no `requestAnimationFrame` in the sim;
- the world steps in typed arrays; ordering is canonical;
- the camera and renderer are pure functions of `(song, seed, frame)`;
- tools and browser share the same `Run`.
What will be previewed is exactly what will be exported.
---
## Build order & status
*Course-corrected in **EVOLUTION.md** — read it before touching the world or the
rules. The front-end (analysis → events → field → laws → palette) stands; the
world behind the `Run` is being rebuilt.*
- **v0 — substrate** ✔ one song, one species, alive on the dish.
*Built:* analysis → structures → world → renderer → filmstrips, metrics,
15 tests green.
- **v1 — variation** ✔ species sampled per seed; three reference songs
(drone / runner / dusk) grow measurably distinct worlds (fingerprint
divergence ≫ seed divergence; fitness separates them).
- **v2 — story** laws switch at section boundaries (done in the world);
event-reactive camera cutting (done in camera.js); filmstrip + report tools
wired.
- **M1 — macro world** (EVOLUTION.md) replacing the entity sim with a
population-field sim: `FIELD=128` density grid (rendered at 256² texture),
`SUBSTEPS` per frame, coarse-grid forces, home basins, speck-swarm rendering
("billions" as texture), song-specific openings. The invariants, determinism
and the `Run` interface survive.
- **M2 — rule program** ✔ the genome/laws become a compiled, declarative
per-song rule program: castes as rule entries (mutualism, social floors),
interaction rules, per-section operator regimes (structural section
changes), the event→response mapping, and a shared carrying capacity.
- **M2.7 — one species, many minds** ✔ built: tribes/castes dissolved into a
single population field with two live properties — a **strain mix** of
song-compiled LittleVM personalities evolving by local selection, and an
**allegiance field** whose macro-organisms ("bodies") hold territory, wage
war (which kills, never converts) and colonise virgin ground — on top of
the living well timeline. The world's economy is food-limited: colonies
bloom on wells, starve off bare ground, and must keep moving to keep
eating.
- **M2.8 — rock** ✔ built: the inert resource — scattered at birth, dropped
and shattered by the song, impassable, quarried by builders into carried
burden and soil, walled where the colony works. See EVOLUTION.md.
- **M2.9 — flora, gardens, walls, farmers, aimed work** ✔ built: the
interactable layer — see EVOLUTION.md.
- **M3 — lens** the camera travels (track / reveal / establish) and reserves
cuts for musical punctuation.
- **M4 — film** mux frames + PCM to video.
## Current state, honestly
The world is alive, the songs diverge on the metrics, and the population is
now one species of many minds: strains compiled by the song, bodies that hold
territory and war over living wells. It is not yet a *film*: the reference
tracks are synthetic test signals, the lens still prefers the biggest knot to
a traveling shot (M3), and muxing is waiting (M4). The allegiance layer is
deliberately rigid — bodies never switch sides — so territories read; the
drama currently comes from war, colonisation and the wells' own lives.
+389
View File
@@ -0,0 +1,389 @@
# evolution — from a petri dish to a world
Course correction after watching the demo, agreed with the code:
**What works.** The song→analysis→events→wells→laws front-end genuinely connects
to the sim (coupling/residual and fingerprint divergence are real). The
determinism discipline is intact and now proven bit-for-bit in the browser.
The demo shell and the tools share one `Run`.
**What failed.** The *world behind the Run*. All songs grow the same three
castes on the same 128² dish with the same opening and the same laws-as-multipliers.
Statistically different, structurally identical. Two skins, not two habitats.
And the camera watches it at the microscopic scale, so nothing larger ever has
room to appear.
**The correction** is one idea: the world must be a *macro* ecology — population
density, not individual dots — governed by *structural rules* the song compiles
to, watched by a camera that *travels* instead of cutting.
---
## Milestones
### M0 — agreement ✔
Representation: **population fields**. Rules: **a scoped rule program** (compiled
per song, deterministic). Plan: this document. `DESIGN.md` updated to the new
end-goal.
### M1 — the macro world: density, substeps, geography ✔
The entity sim is replaced by a population-field sim.
- **World**: `FIELD=128` integrated density grid (rendered as `RENDER_FIELD=256`
speck texture); layers: food, three caste populations, three tinted trail
fields, wells, a static seeded jitter pair.
- **Time**: `SUBSTEPS=5` sim steps per rendered frame (the old world: 1), so
local interactions propagate into coherent macro patterns.
- **Physics**: forces are integrated on a coarse 64² grid (2×2 block sums —
long-range coupling at a fraction of the cost), then advected on the fine
grid with a square-root-free L1 axis split. Reaction: eat → logistic growth
with a total-density gate → deposit; starvation on empty ground. Events still
splat food and bloom the voice that spoke; rings land on strong hits.
- **Home basins**: each caste anchors to its own wells (the field-world
"builder orbits its well"), so colonies persist as continents instead of
melting into a uniform haze.
- **Look**: trails compose the geography; density renders as a glow plus
deterministic speck swarms — "billions of little guys" is texture, at zero
entity cost.
- **Opening**: the dish wakes into the song's own first bars.
*Outcome:* at 60 s the population is still geographically structured (8-block
CV 1.5–1.9 vs ~0.1 before), songs and seeds diverge at a glance in-browser,
openings differ per song, realtime holds at 640×360 (960×540 on reasonable
hardware), and the test suite is green including a prepared-artifact
bit-identity check. *M2 then replaced the multiplier laws with the rule
program; the camera's travel grammar is M3.*
### M2 — the rule program: structure, not knobs ✔
The laws-as-multipliers are replaced by a **scoped rule program**
(`src/song/rules.js`) — declarative per-song rules:
- **castes** are rule entries: speed / seek / dispersion / cap / growth / eat /
deposit, plus *growth rules* — mutualism (a caste breeds fastest among the
others, so mixed crowds beat monochrome blobs) and a social floor (sparks
only bloom in a luminous crowd);
- **interaction rules** per caste pair (the signed band-correlation graph);
- per-section **operator regimes** — a breakdown drives disperse≈0 and
deposit high (the world settles and inscribes); a drop drives home≈0 and
turbulence+spawn high (the flood). Structural, scoped by the track's own
character (noisy → turbulence up, tonal → deposit up);
- the **event→response** mapping: which voice feeds which caste, and how much
of every hit splashes to the others;
- **memory** (trail/field half-lives, diffusion) and a **global carrying
capacity** so one song cannot flood while another starves;
- compiled once per song, deterministically — the program is part of the song,
rebuilt bit-identically by the prepared-artifact path, and v3's rule search
now searches *programs*, not floats.
*Outcome:* 23 tests green, including: program compilation is deterministic;
different songs get structurally different programs; regimes are structurally
distinct by kind (a growth=0 edit visibly halts the world — the program is
executed, not decoration); genesis pre-simulates generations so frame 0 is
already an ecosystem, with the amount tuned per song; the opening is seeded
where the song's own hits landed. Mixing is a program rule: castes co-mingle
in one shared geography (drone 0.93, dusk 0.78, runner 0.48 block-mix; was
~0.02–0.15), the lens holds the whole world (zoom ~0.9–1.6), and divergence
between songs grew to 16–40× where seeds stay ~0.01–0.05.
**M2.5 — song-driven life, more generations, higher altitude ✔**
- *The song spawns:* colonies are no longer placed at a pre-chosen set of
spots. Every hit lands food *and* births life there (the voice first and
loudest, splashes to the others); the drop regime visibly floods the ground
with new colonies where the track hits.
- *Genesis:* the world pre-simulates several generations before frame 0 (the
amount comes from the song — intro length, onset density, pulse), so the
opening is an old, settled ecosystem, never a blank plate, and the film's
history starts clean at frame 0.
- *Altitude:* the lens sits higher (zoom ~0.6–1.5), holding the whole map —
different cultures and the roads and fronts between them — in frame.
### M2.6 — the tribes are programs ✔
The M2 rule program steered *how hard* behaviours ran. Tribes go deeper: each
tribe is a small **deterministic behaviour program** (`src/song/tribe.js`) — a
tiny VM with
- **sensing** (food, own/other densities, trail, law, hunger, rim-edge, time),
- **memory** (four persistent registers, smoothed by the track's pulse),
- **actions** (move, excrete, eat, hit, spawn),
- **basic branching** (if hunger > threshold → seek food, else hold-and-ink),
compiled **by the song**: the band-correlation matrix signs who a tribe
approaches vs flees, flatness sets how much it branches on gradients, onset
density sets its spawning, the seed jitters thresholds. The world no longer
executes a hand-tuned force formula on the coarse decision grid — it runs what
each tribe's program decides, every substep. Surprising behaviour is *possible*
precisely because the programs branch: a starving tribe behaves differently
from a fed one, and the threshold is musical.
*Outcome:* the three societies are now balanced and mixed (drone 0.86 /
runner 0.87 / dusk 0.95 block-mix; populations 40–76 k, castes near-equal), the
geography holds (population CV 2.2–2.8). New tests pin the VM: programs compile
deterministically and differ by song (restless/social/hoarding/breeding
archetypes selected from the stats); branching changes behaviour; registers
persist as memory; a no-op program visibly changes the film. Realtime holds at
640×360 (tribe runs add ~4 ms/frame at 960×540 in node).
*Honest limit:* the compiler's vocabulary is still a small archetype family, so
the *structural* divergence between songs is modest (cross ≈ 0.16–0.18 vs
within-seed ≈ 0.02–0.04, ~4–7×) even though the films look different. Broadening
the instruction grammar (more branch shapes, richer memory, more action
archetypes) is the natural next step for this layer.
### M2.7 — one species, many minds: strains & allegiances ✔
Built — with one correction agreed mid-build, after watching the first films:
> **Allegiance does not change sides.** The original draft had fronts
> converting, collapsed bodies leaking their members, splinters seceding and
> friendly bodies merging. On screen that read as one thing: every colour
> blurring into every other within a minute — ugly, and it erased the very
> polities the mechanic was meant to create. So the fluid politics were
> removed: bodies are founded once and persist; war kills, it does not
> convert; there is no defection and no merger. What keeps the cast plural is
> **colonisation** — a blessed settlement on virgin ground, far from every
> body core, plants a new flag. And the renderer now paints each cell in its
> DOMINANT body's colour, so territories read as territories.
What was built:
- **strains** (`src/song/strains.js`) — the song decides how many minds it
needs (2–5, from onset density, an independent spectral voice, texture
noisiness, seed jitter) and compiles a LittleVM program per strain from
motif roles: builder, grazer, scout, sentinel, breeder. Senses include the
body's presence (KIN/FOE), the living wells, and the section's PEACE — a
program can branch on war or truce. The world runs local selection:
strains that thrive where they stand propagate.
- **living wells** (`src/sim/wells.js`) — a precompiled deterministic
timeline of small, weak, numerous wells (4+ at open, ≤12 alive): born where
a pitch class enters and holds, drifting along its harmony's compass,
starving when it leaves. A birth is a land-rush; a death a retreat.
- **bodies & diplomacy** — allegiance is a concentration field advected
frozen-in with the population flux (Σ bodies = density exactly, a test).
Diplomacy = the track's consonance bent by the section's peace operator;
hostile fronts bleed the loser (casualties, never conversion). Colonisation
plants new bodies on blessed virgin ground.
- **a food-limited economy** — the old world was accidentally
cap-limited: the gate floor let life grow on bare ground, so the dish
filled to a uniform saturated carpet and stopped moving. Now the gate is
nearly shut without food and starvation dissolves colonies off bare
ground: the population tracks its wells, blooms on them, and must keep
moving to keep eating.
*Outcome:* 39 tests green, including the allegiance invariant (no negatives,
composition = population), war-kills-not-converts (a war world ends poorer
than its truce twin, with each body's mass still in its own heartland),
colonisation, strain-mix drift, well determinism/drift/land-rush. Realtime
holds at 640×360 (~12 ms/frame sim+render). Songs still diverge ≫ seeds on
the fingerprint (classMix now sorted — the seed permutes the casting, the
song decides its shape).
### M2.8 — rock: the world gains an inert resource ✔
The films were alive but weightless: nothing on the map was *stuff*. Rock is
the correction — an inert resource with mass, presence and consequence:
- **the field** (`world.rock`) — a rock height per cell; above 0.55 the cell
is impassable (advection aims mass at stone and the mass stays, piling
against the wall; diffusion only crosses open edges). Food does not recharge
through rock: capped ground is barren until someone quarries it open.
- **the song's weather** — the map is born with a deterministic scatter of
stone (7–15 blobs, from the seed). Strong low/mid hits DROP rock where they
land (and crush what they land on); strong high hits SHATTER it. The
section regimes gained `rockfall` and `erode` operators: drops rain stone,
breakdowns weather it away.
- **the masons** — builders gained two VM actions: **dig** (a slow bite out
of the rock, which becomes carried burden — and grinding yields soil, a
little food, right where they dug) and **build** (the carried burden rises
as a wall where the ground is clear). The carry lives in per-block registers
of the world, so stone genuinely travels: quarries open, roads run from
them, walls rise where the colony loiters.
- **livelier organisms** — slower metabolisms (longer lives off food), faster
strains, softer bare-ground starvation: agents travel farther between
wells, meet more, and work more.
*Outcome:* 44 tests green, with new pins: the deterministic scatter; the
song's rockfall outpacing a mute sky; a stone wall holding a crowd west of it
for 10 s straight; quarries opening with soil and burden appearing; carried
rock rising as walls. The strips read it directly: grey masses the colonies
grow around, fronts grinding along them.
### M2.9 — flora, gardens, walls: a world of workable things ✔
After M2.8 the films had stone but nothing to DO with it, and the ecology
still lived on wells alone. This iteration added the interactable layer —
items with feedback loops, each needing its own LittleVM instructions, hence
real specialisation:
- **aimed work** — the VM gained an AIM (two pseudo-actions setting a heading;
it defaults to the direction of travel), and every work action lands at the
block the agent is FACING, never beneath its feet: masons carve the face of
the wall ahead, farmers tend the bed they walk toward, strikers hit where
the enemy stands. The work-senses (rock, garden) look where the last aim
pointed — sight follows intent.
- **gardens** — the mason TILLS fresh stone into a cultivated bed (brought to
maturity one cell at a time; a dusting of fertility would exhaust as fast
as it is laid). A bed yields food slowly for a while, then the spent soil
sets back into rock: agriculture slowly eats the world's stone, and the
song's rockfalls replenish it. Digging itself yields NO food — stone is for
building; food comes from wells, flora, events and gardens.
- **walls** — the carried burden rises as a wall (pooled per block, mortared
along a rounded footprint). Walls block movement, so they defend; enemies
strike at their aim target and SAP the stone at the front — sieges happen,
and unmaintained walls weather away. The masons are never done.
- **farmers** — a new strain role: it walks the field rows and TENDS the beds
(a tended bed yields up to threefold), opens new beds into fresh stone, and
commutes along the garden gradient. Work is foraging: the hungry branches
of mason and husbandman work the ground they aim at, because that is where
their food comes from.
- **flora** — the world's pioneers, living in ISLANDS scattered at birth
(a dozen small patches; they can never spread beyond their own seed
ground). Each stand is food — grazed down by hungry crowds, never wiped out
(seed-rain), regrowing slowly. Its vigour tracks the world's famine: a
starving or dwindling world blooms with flora and is seeded back to life;
a strong, well-fed world grazes it back to a whisper. Rendered as speckles
— a few apple trees and bushes here and there, never a garden of Eden.
*Balance notes carved into the tests:* the ecology is food-limited (the gate
is nearly shut on bare ground; colonies that outgrow their ground starve back
and wander); rock has a budget that survives a whole film (calm songs keep
sparse stone, percussive ones accumulate walls); agriculture consumes stone
instead of minting it. 44 tests green; realtime holds at 640×360.
### The region pool + the browser as workhorse (landed)
**Node stays CPU** — it is the dev, test and export path, simple on purpose.
**The browser is the workhorse**: it renders, it previews, and it has the
GPU. The compute architecture, in three layers:
1. **Think slower than you move** (landed): the strains decide every
`vmEvery`-th substep (default 2) and empty ground never thinks. Measured
at field 128 with 41 k organisms: 25 → 20 ms/frame, ecology unchanged.
2. **The region pool** (landed): the field is carved into horizontal
coarse-row bands; every shared array lives in ONE SharedArrayBuffer
(`arenaLayout`), and `World`'s substep splits into three band-local
phases — block sums, the strain programs, advection+reaction — separated
by atomics barriers (`beginPhase`). The main thread keeps only the
cross-band work: the war front (hits land at aim targets), the swap, the
rock economy, politics, decay. The mail carries the frame, the decide
flag, warmth, the living bodies and the current swap orders (the
workers' named arrays follow the main's swaps through the mail).
**Measured: 52.3 → 14.7 ms/frame (2 workers) → 8.9 ms/frame (4 workers)
at 41 k organisms — near-linear.** And the invariant that makes it safe:
the phases are EXACT partitions, so a parallel world is the serial world
BIT FOR BIT (pinned by a test across 0/2/4 workers). Node runs it on
`worker_threads` (lazily resolved, so the browser still loads the
module); the browser will run the same pool inside its simulation worker.
3. **The GPU** (first slice landed): the strain programs are uniform over
thousands of blocks — one dispatch per strain covers the whole decision
layer. `src/sim/gpu.js` + `tools/gpu-check.js` (browser-side validation
against the CPU interpreter) await device time.
**The browser as the server's worker (protocol sketch, next).** Once
rendering exists, the browser becomes a compute provider to the node farm:
- the farm (node) holds prepared songs (`out/prepared/*.json`) and a job
queue: `(songId, seed, frameFrom, frameTo)`;
- a browser provider connects (WebSocket), registers its capability
(GPU? cores?), receives a prepared artifact + a frame range, runs
`Run` inside its worker pool with the GPU decision layer, and streams
back encoded frames (PNG or raw) keyed by frame number;
- the farm stitches the frames into the film. Sim runs are sequential in
time, so the parallel unit is the TAKE (a song × seed × range), not a
frame — many takes in flight across many providers, with the farm
checkpointing world state at range boundaries so a provider can resume.
Determinism policy unchanged: node's CPU path is the canonical exporter;
the GPU/preview paths are the same world to within float noise.
### The decision layer on the GPU (analysis, first slice landed)
The profile of a heavy frame (field 128, ~40 000 organisms planted) says:
| layer | share |
|---|---|
| the strain VMs (`runProgram`) | ~35% |
| senses/gradients/action plumbing (`runStrains`) | ~20% |
| field passes (block sums, reaction, `substep`) | ~16% |
| advection (+allegiance, burden) | ~7% |
| camera + render | ~10% |
Three moves, in the order they pay:
1. **Think slower than you move (landed).** The programs ran at 150 Hz —
every substep — though a mind's decisions barely change in 1/30 s. The
world now thinks every `vmEvery`-th substep (default 2: ~15 thoughts/s)
and coasts on its last decision between thoughts; blocks with no
population think not at all. Measured on the heavy world
(41 k planted, sim only): 25.0 → 20.3 ms/frame at ×2, 17.7 at ×3 — with
statistically identical ecologies (38.6 k vs 38.7 k final). The tests
pinned the rest.
2. **The GPU (first slice landed, experimental).** The decision layer is
ideal GPU work: m uniform programs over ~4 000 independent blocks —
lockstep, only the data differs. `src/sim/gpu.js` holds a WebGPU compute
path for exactly that piece: senses/grads packed on the CPU, the flat
bytecode uploaded per strain, registers resident on the device, one
dispatch per strain, the action buffer read back per substep.
`tools/gpu-check.js` (browser-side) validates the GPU result against the
CPU interpreter and times both. Status: written against the WebGPU API,
awaiting a device to run on; NOT yet wired into `World.step` — that
wiring needs an async stepper (the readback is asynchronous), which the
demo can adopt and the exporter need not.
3. **The rest of the field** (block sums, advection, reaction) is the next
and larger GPU slice — element-wise and stencil passes over the fields,
with the population/politics staying on the CPU. That is what unlocks
field 256 and fatter casts.
**Determinism policy.** GPU float ordering differs from the CPU's, so a
GPU-accelerated preview may drift slightly from a CPU-exported film. The
policy: the prepared-artifact/export pipeline stays CPU and remains the
canonical pixel source; the GPU path is a preview accelerator whose world
is the same world to within float noise. If preview/export bit-identity is
ever demanded, the exporter must run on the same backend as the preview —
a node WebGPU binding (e.g. a wgpu native addon) is the door for that, and
it is explicitly out of scope until the film needs it.
### M3 — the lens: travel, don't cut
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
moving front), *reveal* (pan along a coastline), *establish* (hold wide and
slow). Cuts are reserved for musical punctuation — strong onsets in drop/build.
Because the world is macro, a pan teaches the map instead of disorienting.
*Exit: a 120 s render where a single front can be followed across the world
without losing bearings; cuts read as beats, not jumps.*
### M4 — the film
Mux frames + PCM to video. No longer the bottleneck: the preview pipeline and
the lens exist; once M1–M3 make the world worth exporting, muxing is plumbing.
---
## The prepare step (pre-calculation)
Viewing should be fast because the *expensive* work happens ahead of time, once:
1. `tools/prepare.js` runs the WAV → analysis → compile pipeline in node and
writes a **prepared song** (`out/prepared/<song>-<seed>.json`): the compiled
rule program and every analysis structure the world, camera, and metrics
need — but not the raw PCM.
2. The browser fetches the prepared song instead of analysing the WAV. The WAV
is still fetched, but only to feed the WebAudio clock (decoded straight to a
buffer; analysis skipped).
3. `src/song/serialize.js` is the single serialize/deserialize for both sides —
the browser's `deserializeSong` must rebuild a song that is *bit-identical*
to one built by analysing the WAV, so prepare can never drift from the live
path. That equality is a test.
This is exactly the DESIGN promise inverted and kept: the pipeline still defines
the world; prepare only pays its cost once, off the viewing path.
## Invariants, restated for the field world
- **Determinism**: same song + same seed + same frame ⇒ same pixel. No
`Math.random`, no wall clock, no per-substep RNG. The static jitter noise
field is generated once from the seeded RNG at construction.
- **Causality**: events still place food and rings where the track put them;
a section boundary still changes the rules at the boundary only.
- **Persistence**: population fields carry their history; trails accumulate in
the same three tinted layers the renderer already composes.
- **Consequence**: everything is a pure update rule over the previous substep;
nothing is re-rolled.
+173
View File
@@ -0,0 +1,173 @@
# metabolism
Songs as **substrate**. A music video generator whose outcome is grown, not
mixed: the track is not a parameter set the renderer modulates — it is the
world an agent ecology grows on. Same song + same seed ⇒ the same world,
bit for bit, frame for frame, preview identical to export.
```
song (PCM) ──► analyse ──► score · field · graph · laws ──► species ──► world ──► camera ──► frames
```
The full concept and rationale live in **[DESIGN.md](DESIGN.md)** — read it
before touching the code.
---
## The idea in one paragraph
Most visualisers *modulate* one fixed stage: the song turns knobs, and every
track ends up presenting the same toy in different clothes. Here the song
places the **food** (events land where the track's harmonies point), the **laws**
(each section changes the rules at its boundary), and the **species** (the
seed picks a rule set — built from tempo, noise, brightness and band
correlations — not a parameter vector). Two songs differ like two habitats
differ, not like two skins. `drone` grows a slow tonal weaver with heavy,
lasting ground markings; `runner` grows a fast noisy swarm with jittered
motion and thin ephemeral trails; `dusk` grows a crystalline, slow,
over-clustered world where the sparks reject the builders.
## Invariants
Everything is load-bearing to keep these true:
- **Persistence** — agents and their trails last seconds to tens of seconds.
- **Causality** — every visible event has an audible cause (hits are rings on
the ground) and a visible one.
- **Consequence** — the field is eaten, the trails accumulate; state at `t` is
made of state at `t−1`, never re-rolled.
- **Determinism** — no `Math.random`, no wall clock; tools and browser share
the same `Run`, so preview provably equals export.
---
## Quick start
Requires Node 20+ (for `node --test`) and your usual package manager.
```bash
npm install
# tests: analysis, determinism, world invariants, metrics sanity
npm test
# filmstrips: 12 frames per song, tiled
# (needs the flow-state test bank WAVs at ../flow-state/test/songs)
npm run strip # drone, runner, dusk
npm run strip -- --songs drone --cols 6 --rows 2 --out out
npm run strip -- --songs drone,runner,dusk --combined 1 # comparison sheet
# report: coupling × residual fitness + fingerprint divergence across songs/seeds
npm run report # prints a table, writes out/report.md + report.json
npm run report -- --songs drone,runner,dusk --seeds 999,12345,777777
# prepare: analyse songs once, so the demo loads instantly instead of
# re-analysing the WAV in the browser (falls back to live analysis if absent)
npm run prepare # writes out/prepared/<song>-<seed>.json
```
Outputs land in `out/` (gitignored).
## The demo
```bash
npm run dev # open http://localhost:5173 and pick a song
```
A browser demo that plays the songs off the actual pipeline. The page serves
the bank from `../flow-state/test/songs` (via `vite.config.js` — no copies),
lists **all 17 tracks** with the species each one grows, and plays the chosen
track while the world steps to it:
- **select a song** → it fetches the WAV, decodes it, and grows the world to
frame 0 before letting you in. Run `npm run prepare` once and the browser
loads the pre-computed analysis instead of re-analysing, so viewing is fast
(it falls back to live analysis when an artifact is missing);
- **play / pause** (or <kbd>space</kbd>), **seek** (or <kbd>←</kbd>/<kbd>→</kbd>
±5 s) — the audio clock only chooses *which* frame the world should have
reached, so seeking is fast forward; a backward seek re-grows from frame 0;
- **seed** (the exact world), **res** (640×360 realtime default, up to
1280×720) and **volume**; changes live without breaking playback;
- the seek bar shows **section ticks** (the laws), the status line shows the
current law, shot size/zoom and live world stats (population, field, trail);
- the page accent **wears the track's palette hue**, and the URL carries
`?song=&seed=&res=` so a film is a link.
Determinism is the point: the page renders through the same `Run` as the
tools — same song, same seed, same frame ⇒ the same pixels (tested bit-identical
against `strip` output, including the prepared-artifact path). `npm run build`
bundles it; `npm run preview` serves the built page from `dist/`. The world
behind the `Run` is the **macro ecology** of EVOLUTION.md M1 — population
fields, not entities; see `EVOLUTION.md`.
Files: `index.html`, `vite.config.js` (song-bank + prepared-artifact plugin),
`src/demo/*` (`main.js` page wiring, `player.js` Run→canvas, `audio.js`
WebAudio clock, `catalog.js` metadata, `style.css`).
## Layout
```
DESIGN.md concept, invariants, build order (end-goal)
EVOLUTION.md course-corrected roadmap (M1 done, M2–M4 ahead)
index.html the demo page
vite.config.js song-bank + prepared-artifact plugin (dev + build)
src/
audio/ WAV decode, radix-2 FFT, feature table, sectioning, onset score
song/ events (onsets → geometry), species (genome), rules.js (the
per-song rule program + section operator regimes),
tribe.js (the per-tribe behaviour VM: senses, memory,
actions, branching — compiled from the song), palette,
song assembly, serialize.js (prepare cache)
sim/ wells (harmony→food) and world.js (the macro field-world:
128² density, 5 substeps/frame, executes the rule program
and the tribe programs)
render/ software framebuffer: trail composer, density glow + speck
swarms (the "billions"), PNG encoder
camera.js wide event-reactive shots, shot-size rhythm, drift-and-push
film.js Run: the single wire-up of sim → camera → framebuffer
metrics.js coupling, residual, fitness, fingerprint, divergence
rng.js seeded PRNGs (the only randomness in the repo)
demo/ player page: main.js wiring, player.js (Run→canvas),
audio.js (WebAudio clock), catalog.js, style.css
tools/
load.js WAV → analysis → song (node-only)
prepare.js pre-computes analysis → out/prepared/<song>-<seed>.json
strip.js filmstrip renderer
report.js system-level evaluation → markdown table
test/ node:test suites (incl. prepared-artifact bit-identity)
```
## References
`tools/*` load the synthetic reference tracks from
`../flow-state/test/songs` (`drone`, `runner`, `dusk`) — three 120 s test
signals chosen to cover the axes the species reads: dark/tonal/beatless,
bright/noisy/fast, darkest/all-tonal/mid-tempo. Drop any WAV there and point
`--songs` at it; the species will derive from whatever the track actually says.
## Status
M1, M2 and M2.6 of **EVOLUTION.md** are done: the entity world is a **macro,
population-field ecology** (density, substeps, coarse-grid forces, speck
rendering); the rules that run it are a **declared, compiled rule program**
(per-caste rule entries, interaction rules, per-section operator regimes, the
event→response mapping); and the tribes are **small deterministic programs** —
sensing, memory, actions and branching, compiled by the song and executed on
the decision grid every substep. Songs grow balanced, *mixed* societies (block
mix 0.86–0.95) with structured geography, and the opening is a genesis-settled
world, not a blank plate. Everything is deterministic and bit-identical across
the demo, the tools and the prepared-artifact path (28 tests green). It is
**not yet a film** — the camera still cuts rather than travels (M3) and nothing
is muxed to a video file (M4).
## Auditing what it grew
The report's **coupling × residual** fitness is the honest score: coupling is
how much the ecology answers the track; residual is the internal dynamics the
track does not explain. A pure visualiser scores ~0 (no residual); a random
walk scores ~0 (no coupling). The fingerprint (occupancy, clustering, trail
fill, class mix) tells you whether two songs grew *different* worlds without
anyone having to opine about it.
<!--- _Note: this README documents the design intent; the full argument for
every mechanism is in DESIGN.md._ -->
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>metabolism — songs as substrate</title>
<link rel="stylesheet" href="/src/demo/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-text">
<h1>metabolism</h1>
<p class="tagline">song in → world out. pick a track, grow its film.</p>
</div>
</div>
<div class="controls">
<label class="ctl" title="The seed picks the exact world a song grows. Same song + same seed is the same film, bit for bit.">
<span>seed</span>
<input id="seed" type="number" min="1" max="4294967295" step="1" value="12345" />
</label>
<label class="ctl" title="Render resolution. 640×360 is the realtime default; higher is heavier.">
<span>res</span>
<select id="res">
<option value="640x360">640 × 360</option>
<option value="960x540">960 × 540</option>
<option value="1280x720">1280 × 720</option>
</select>
</label>
<label class="ctl" title="Main volume">
<span>vol</span>
<input id="volume" type="range" min="0" max="100" step="1" value="90" />
</label>
</div>
</header>
<main class="layout">
<aside class="library">
<div class="library-head">
<h2>Library</h2>
<span id="lib-count" class="muted"></span>
</div>
<ul id="songs" class="songs"></ul>
</aside>
<section class="stage">
<div class="screen">
<canvas id="view"></canvas>
<div id="overlay" class="overlay" hidden></div>
</div>
<div id="caption" class="caption">…</div>
<div class="transport">
<button id="play" class="play" aria-label="play / pause">▶</button>
<div class="seekbar">
<div class="track" id="track">
<div id="fill" class="fill"></div>
<div id="ticks" class="ticks"></div>
<input id="seek" class="seek-input" type="range" min="0" max="1000" step="1" value="0"
aria-label="seek" />
</div>
<div class="times">
<span id="t0">0:00</span>
<span id="t1">—</span>
</div>
</div>
</div>
<div class="statusline">
<span id="law" class="law">—</span>
<span id="shot" class="muted">—</span>
<span id="hud" class="muted">—</span>
</div>
<p class="footnote">
one run, no clock: the world steps to the song, the camera answers the world, and the
same <code>Run</code> renders here and in the export tools. no <code>Math.random</code>,
no wall clock — same song · same seed · same frame ⇒ same pixel.
</p>
</section>
</main>
<script type="module" src="/src/demo/main.js"></script>
</body>
</html>
+1058
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "metabolism",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node --test test/*.test.js",
"strip": "node tools/strip.js",
"report": "node tools/report.js",
"prepare": "node tools/prepare.js"
},
"devDependencies": {
"vite": "^7.2.2"
}
}
+410
View File
@@ -0,0 +1,410 @@
// PCM -> per-frame feature table. Whole track, before the first frame renders.
//
// Nothing here reacts in realtime. The table is the song's score as numbers:
// band energy, spectral flux, centroid, flatness, a dominant pitch, and the
// places where the track changes. Everything downstream reads this table, so
// preview and export cannot diverge.
import { Fft, hann } from './fft.js';
export const DEFAULT_FPS = 30;
const BANDS = [
[30, 160], // low — weight, pulse, foundation
[160, 2000], // mid — body, voice, harmony
[2000, 11000], // high — air, noise, detail
];
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
function percentile(values, p) {
const a = Float32Array.from(values);
a.sort();
const i = Math.min(a.length - 1, Math.max(0, Math.round((a.length - 1) * p)));
return a[i];
}
function normalise(series, loP = 0.03, hiP = 0.97) {
const lo = percentile(series, loP);
const hi = percentile(series, hiP);
const k = hi - lo > 1e-9 ? 1 / (hi - lo) : 0;
const out = new Float32Array(series.length);
for (let i = 0; i < series.length; i++) out[i] = clamp01((series[i] - lo) * k);
return out;
}
/** Local-maximum picking with an adaptive threshold. Returns discrete events. */
export function pickOnsets(flux, fps, { minGap = 0.07, threshold = 1.35, window = 0.5 } = {}) {
const n = flux.length;
const w = Math.max(1, Math.round(window * fps));
const gap = Math.max(1, Math.round(minGap * fps));
const events = [];
let sum = 0;
for (let i = 0; i < n; i++) {
sum += flux[i];
if (i - w >= 0) sum -= flux[i - w];
const mean = sum / Math.min(i + 1, w);
if (i < 2 || i >= n - 1) continue;
if (flux[i] <= flux[i - 1] || flux[i] < flux[i + 1]) continue;
const floor = mean * threshold + 0.004;
if (flux[i] < floor) continue;
if (events.length && i - events[events.length - 1].frame < gap) {
if (flux[i] > events[events.length - 1].strength) {
events.pop();
} else {
continue;
}
}
events.push({ frame: i, t: i / fps, strength: clamp01((flux[i] - floor) / Math.max(1e-6, 1 - floor)) });
}
return events;
}
/** Tempo from the onset envelope's autocorrelation. */
function estimateTempo(onset, fps) {
const minLag = Math.round((fps * 60) / 200);
const maxLag = Math.round((fps * 60) / 50);
let best = 0;
let bestLag = 0;
let total = 0;
for (let i = 0; i < onset.length; i++) total += onset[i] * onset[i];
for (let lag = minLag; lag <= maxLag; lag++) {
let s = 0;
for (let i = lag; i < onset.length; i++) s += onset[i] * onset[i - lag];
s /= onset.length - lag;
if (s > best) {
best = s;
bestLag = lag;
}
}
if (!bestLag || total <= 1e-9) return { bpm: 0, beatStrength: 0 };
// Normalised autocorrelation: a steady 4/4 lands well above 0.3, a drone
// near 0.1. The sim uses strength, not bpm, to decide whether to march.
return { bpm: (60 * fps) / bestLag, beatStrength: clamp01(best / (total / onset.length)) };
}
/**
* Section boundaries: where the 4 Hz feature cloud changes its mind.
* A boundary is a peak in the distance between the mean of the eight buckets
* before it and the eight after it — cheap self-similarity, no FFT of the FFT.
*/
function findSections(frames4, fps4, duration) {
const n = frames4.length;
if (n < 8) return [{ start: 0, end: duration, index: 0 }];
const half = 8;
const novelty = new Float32Array(n);
for (let i = half; i < n - half; i++) {
let d = 0;
for (let k = 0; k < 6; k++) {
let a = 0;
let b = 0;
for (let j = 1; j <= half; j++) {
a += frames4[i - j][k];
b += frames4[i + j][k];
}
d += ((a - b) / half) ** 2;
}
novelty[i] = Math.sqrt(d);
}
const peak = Math.max(...novelty);
if (peak <= 1e-8) return [{ start: 0, end: duration, index: 0 }];
for (let i = 0; i < n; i++) novelty[i] /= peak;
const minGap = Math.round(10 * fps4);
const accepted = [];
const guard = Math.round(4 * fps4);
for (let i = guard; i < n - guard; i++) {
if (novelty[i] < 0.45) continue;
if (novelty[i] < novelty[i - 1] || novelty[i] < novelty[i + 1]) continue;
if (accepted.length && i - accepted[accepted.length - 1] < minGap) {
if (novelty[i] > novelty[accepted[accepted.length - 1]]) accepted.pop();
else continue;
}
accepted.push(i);
if (accepted.length >= 6) break;
}
let cuts = [0, ...accepted.map((i) => i / fps4), duration];
// Absorb sections shorter than four seconds: a boundary that survives for
// two seconds is a swell, not an era. The sim cannot change laws that fast.
cuts = cuts.filter((c, i) => i === 0 || i === cuts.length - 1 || c - cuts[i - 1] >= 4);
const sections = [];
for (let i = 0; i < cuts.length - 1; i++) {
if (cuts[i + 1] - cuts[i] < 1.5) continue;
sections.push({ start: cuts[i], end: cuts[i + 1], index: sections.length });
}
if (!sections.length) return [{ start: 0, end: duration, index: 0 }];
return sections;
}
function classifySections(sections, energyAt) {
const es = sections.map((s) => energyAt(s));
const sorted = [...es].sort((a, b) => a - b);
const p20 = sorted[Math.floor(sorted.length * 0.2)];
const p50 = sorted[Math.floor(sorted.length * 0.5)];
const p80 = sorted[Math.floor(sorted.length * 0.8)];
const kinds = sections.map((s, i) => {
const e = es[i];
const prev = es[i - 1] ?? e;
if (i === 0 && e <= p50) return 'intro';
if (i === sections.length - 1 && e <= p50) return 'outro';
if (e >= p80) return 'drop';
if (e <= p20) return 'breakdown';
if (e > prev + 0.04) return 'build';
return 'sustain';
});
return sections.map((s, i) => ({
...s,
kind: kinds[i],
energy: es[i],
centroid: s.centroid,
flatness: s.flatness,
}));
}
/**
* @param {Float32Array} samples mono
* @param {number} sampleRate
* @param {{fps?:number, fftSize?:number}} options
*/
export function analyse(samples, sampleRate, { fps = DEFAULT_FPS, fftSize = 2048 } = {}) {
const duration = samples.length / sampleRate;
const frames = Math.max(1, Math.floor(duration * fps));
const hop = sampleRate / fps;
const fft = new Fft(fftSize);
const win = hann(fftSize);
const re = new Float32Array(fftSize);
const im = new Float32Array(fftSize);
const mag = new Float32Array(fftSize / 2);
const prevMag = new Float32Array(fftSize / 2);
const nyquist = sampleRate / 2;
const binHz = nyquist / (fftSize / 2);
const bandBins = BANDS.map(([lo, hi]) => [
Math.max(1, Math.floor(lo / binHz)),
Math.min(fftSize / 2 - 1, Math.ceil(hi / binHz)),
]);
const energyRaw = new Float32Array(frames);
const bandsRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)];
const fluxRaw = new Float32Array(frames);
const bandFluxRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)];
const centroidRaw = new Float32Array(frames);
const flatnessRaw = new Float32Array(frames);
const dominantRaw = new Float32Array(frames);
const chroma = new Float32Array(12);
for (let f = 0; f < frames; f++) {
const start = Math.min(samples.length - fftSize, Math.floor(f * hop));
let rms = 0;
for (let i = 0; i < fftSize; i++) {
const s = start + i >= 0 ? samples[start + i] || 0 : 0;
re[i] = s * win[i];
im[i] = 0;
rms += s * s;
}
energyRaw[f] = Math.sqrt(rms / fftSize);
fft.forward(re, im);
let sumP = 0;
let sumLog = 0;
let weighted = 0;
let peakBin = 1;
let peakMag = 0;
let flux = 0;
for (let b = 1; b < fftSize / 2; b++) {
const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]) / (fftSize / 2);
mag[b] = m;
const p = m * m + 1e-12;
sumP += p;
sumLog += Math.log(p);
weighted += b * binHz * p;
if (m > peakMag) {
peakMag = m;
peakBin = b;
}
const hz = b * binHz;
if (hz > 45 && hz < 2400) {
const semis = Math.round(12 * Math.log2(hz / 440));
const pc = ((semis % 12) + 12 + 9) % 12;
chroma[pc] += m * m;
}
flux += Math.max(0, m - prevMag[b]);
}
const count = fftSize / 2 - 1;
// Log-frequency centroid: 50 Hz -> 0, Nyquist -> 1. A linear Hz centroid
// sits near 0.02 for every real track and cannot tell dark from bright.
const meanHz = weighted / Math.max(sumP, 1e-12);
centroidRaw[f] = clamp01(Math.log2(Math.max(50, meanHz) / 50) / Math.log2(nyquist / 50));
flatnessRaw[f] = sumP > 1e-8 ? clamp01((Math.exp(sumLog / count) / (sumP / count)) * 20) : 0;
fluxRaw[f] = Math.min(1, flux * 4);
dominantRaw[f] = peakBin * binHz;
for (let k = 0; k < 3; k++) {
const [lo, hi] = bandBins[k];
let e = 0;
let fl = 0;
for (let b = lo; b <= hi; b++) {
const m = mag[b];
e += m * m;
fl += Math.max(0, m - prevMag[b]);
}
bandsRaw[k][f] = Math.sqrt(e / (hi - lo + 1));
bandFluxRaw[k][f] = Math.min(1, fl * 4);
}
prevMag.set(mag);
}
const energy = normalise(energyRaw);
const low = normalise(bandsRaw[0]);
const mid = normalise(bandsRaw[1]);
const high = normalise(bandsRaw[2]);
const flux = normalise(fluxRaw);
const bandFlux = bandFluxRaw.map((s) => normalise(s));
const onsets = bandFlux.map((s, k) => pickOnsets(s, fps, { threshold: k === 2 ? 1.5 : 1.3 }));
const combined = new Float32Array(frames);
for (let i = 0; i < frames; i++) combined[i] = Math.max(bandFlux[0][i], bandFlux[1][i] * 0.8, bandFlux[2][i] * 0.5);
const allOnsets = mergeOnsets(onsets, fps);
const tempo = estimateTempo(combined, fps);
// 4 Hz section view.
const fps4 = Math.min(fps, 4);
const per = fps / fps4;
const frames4 = [];
for (let i = 0; i * per < frames; i++) {
const a = Math.floor(i * per);
const b = Math.min(frames, Math.floor((i + 1) * per));
const row = [0, 0, 0, 0, 0, 0];
for (let j = a; j < b; j++) {
row[0] += energy[j];
row[1] += low[j];
row[2] += mid[j];
row[3] += high[j];
row[4] += centroidRaw[j];
row[5] += flux[j];
}
const k = 1 / Math.max(1, b - a);
frames4.push(row.map((v) => v * k));
}
const rawSections = findSections(frames4, fps4, duration);
const sections = classifySections(rawSections, (s) => {
const a = Math.floor(s.start * fps);
const b = Math.max(a + 1, Math.floor(s.end * fps));
let sum = 0;
for (let i = a; i < b; i++) sum += energy[i];
return sum / (b - a);
}).map((s) => {
const a = Math.floor(s.start * fps);
const b = Math.max(a + 1, Math.floor(s.end * fps));
let c = 0;
let fl = 0;
for (let i = a; i < b; i++) {
c += centroidRaw[i];
fl += flatnessRaw[i];
}
return { ...s, centroid: c / (b - a), flatness: fl / (b - a) };
});
const bandCorrelation = correlate3([low, mid, high]);
const dynamicRange = Math.max(0, percentile(energyRaw, 0.96) - percentile(energyRaw, 0.1)) /
Math.max(1e-9, percentile(energyRaw, 0.96));
let key = 0;
let keyEnergy = 0;
const chromaPeak = Math.max(...chroma);
for (let i = 0; i < 12; i++) {
if (chroma[i] > keyEnergy) {
keyEnergy = chroma[i];
key = i;
}
}
const chromaNorm = Array.from(chroma, (v) => (chromaPeak > 1e-9 ? v / chromaPeak : 0));
const summary = {
duration,
bpm: tempo.bpm,
beatStrength: tempo.beatStrength,
centroid: mean(centroidRaw),
flatness: mean(flatnessRaw),
dynamics: clamp01(dynamicRange),
loudness: mean(energyRaw),
onsetDensity: allOnsets.length / Math.max(1, duration),
bandBalance: [mean(low), mean(mid), mean(high)],
bandCorrelation,
chroma: chromaNorm,
key,
sections: sections.length,
kinds: sections.map((s) => s.kind),
};
return {
fps,
frames,
duration,
sampleRate,
bands: { low, mid, high },
bandFlux,
energy,
flux,
centroid: centroidRaw,
flatness: flatnessRaw,
dominantHz: dominantRaw,
onsets: { low: onsets[0], mid: onsets[1], high: onsets[2], all: allOnsets },
sections,
summary,
};
}
function mean(a) {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i];
return s / Math.max(1, a.length);
}
/**
* One event per moment. Bands fire together on a transient; three simultaneous
* events would triple the food at the same instant. Keep the strongest, at
* least 120 ms apart, and carry the voice so the world knows what spoke.
*/
function mergeOnsets(bandOnsets, fps, minGap = 0.12) {
const all = [];
for (let voice = 0; voice < bandOnsets.length; voice++) {
for (const o of bandOnsets[voice]) all.push({ ...o, voice });
}
all.sort((a, b) => a.frame - b.frame || b.strength - a.strength);
const gap = Math.max(1, Math.round(minGap * fps));
const out = [];
for (const e of all) {
const last = out[out.length - 1];
if (last && e.frame - last.frame < gap) {
if (e.strength > last.strength) out[out.length - 1] = e;
continue;
}
out.push(e);
}
return out;
}
function correlate3(series) {
const out = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
for (let i = 0; i < 3; i++) {
for (let j = i + 1; j < 3; j++) {
const a = series[i];
const b = series[j];
const ma = mean(a);
const mb = mean(b);
let num = 0;
let da = 0;
let db = 0;
for (let k = 0; k < a.length; k++) {
const x = a[k] - ma;
const y = b[k] - mb;
num += x * y;
da += x * x;
db += y * y;
}
const r = num / Math.sqrt(Math.max(1e-12, da * db));
out[i][j] = r;
out[j][i] = r;
}
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
// In-place iterative radix-2 FFT. Small on purpose: the analysis needs one
// magnitude spectrum per video frame and nothing else.
export class Fft {
constructor(size) {
if ((size & (size - 1)) !== 0) throw new Error('FFT size must be a power of two');
this.size = size;
this.levels = Math.log2(size) | 0;
this.cos = new Float32Array(size / 2);
this.sin = new Float32Array(size / 2);
for (let i = 0; i < size / 2; i++) {
this.cos[i] = Math.cos((2 * Math.PI * i) / size);
this.sin[i] = Math.sin((2 * Math.PI * i) / size);
}
this.rev = new Uint32Array(size);
for (let i = 0; i < size; i++) {
let r = 0;
for (let b = 0; b < this.levels; b++) r |= ((i >>> b) & 1) << (this.levels - 1 - b);
this.rev[i] = r;
}
}
/** Forward transform in place. `im` is zeroed on entry by the caller. */
forward(re, im) {
const n = this.size;
const rev = this.rev;
for (let i = 0; i < n; i++) {
const j = rev[i];
if (j > i) {
let t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (let len = 2; len <= n; len <<= 1) {
const half = len >> 1;
const step = n / len;
for (let i = 0; i < n; i += len) {
for (let j = 0; j < half; j++) {
const k = j * step;
const c = this.cos[k];
const s = this.sin[k];
const a = i + j;
const b = a + half;
const xr = re[b] * c + im[b] * s;
const xi = im[b] * c - re[b] * s;
re[b] = re[a] - xr;
im[b] = im[a] - xi;
re[a] += xr;
im[a] += xi;
}
}
}
}
}
export function hann(size) {
const w = new Float32Array(size);
for (let i = 0; i < size; i++) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1));
return w;
}
+107
View File
@@ -0,0 +1,107 @@
// Exact WAV decode: 8/16/24/32-bit PCM and 32-bit float, any rate.
// Same samples in the browser and in node, so a strip rendered by a tool and
// a frame rendered by the demo agree.
const ascii = (bytes, o, n) => {
let s = '';
for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]);
return s;
};
function readChunk(dv, bytes, o) {
return {
id: ascii(bytes, o, 4),
size: dv.getUint32(o + 4, true),
start: o + 8,
};
}
/**
* @param {ArrayBuffer|Uint8Array} input
* @returns {{sampleRate:number, channels:Float32Array[], bitDepth:number, format:string} | null}
*/
export function decodeWav(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 44 || ascii(bytes, 0, 4) !== 'RIFF' || ascii(bytes, 8, 4) !== 'WAVE') return null;
let fmt = null;
let data = null;
let o = 12;
while (o + 8 <= bytes.length) {
const c = readChunk(dv, bytes, o);
if (c.id === 'fmt ') {
fmt = {
format: dv.getUint16(c.start, true),
channels: dv.getUint16(c.start + 2, true),
sampleRate: dv.getUint32(c.start + 4, true),
bits: dv.getUint16(c.start + 14, true),
};
} else if (c.id === 'data') {
data = { start: c.start, size: Math.min(c.size, bytes.length - c.start) };
break;
}
o = c.start + c.size + (c.size & 1);
}
if (!fmt || !data) return null;
const { format, channels, sampleRate, bits } = fmt;
const framed = Math.floor(data.size / ((bits / 8) * channels));
const out = Array.from({ length: channels }, () => new Float32Array(framed));
const full = 1 << (bits - 1);
if (format === 1 && bits === 16) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getInt16(data.start + (i * channels + c) * 2, true) / 32768;
}
}
} else if (format === 1 && bits === 24) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
const p = data.start + (i * channels + c) * 3;
let v = bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16);
if (v & 0x800000) v |= -0x1000000;
out[c][i] = v / 8388608;
}
}
} else if (format === 1 && bits === 32) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getInt32(data.start + (i * channels + c) * 4, true) / 2147483648;
}
}
} else if (format === 1 && bits === 8) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = (bytes[data.start + i * channels + c] - 128) / 128;
}
}
} else if (format === 3 && bits === 32) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getFloat32(data.start + (i * channels + c) * 4, true);
}
}
} else {
return null;
}
return { sampleRate, channels: out, bitDepth: bits, format: format === 3 ? 'float' : 'pcm' };
}
/** Average all channels. Mono is the contract for analysis. */
export function mono(channels) {
if (channels.length === 1) return channels[0];
const n = channels[0].length;
const out = new Float32Array(n);
for (let c = 0; c < channels.length; c++) {
const ch = channels[c];
for (let i = 0; i < n; i++) out[i] += ch[i];
}
const k = 1 / channels.length;
for (let i = 0; i < n; i++) out[i] *= k;
return out;
}
export { ascii };
+150
View File
@@ -0,0 +1,150 @@
// The camera is an observer with a motive: it cuts to where the world just
// changed, holds long enough to see what it changed into, and pushes in while
// it holds. It is not a rig on rails.
//
// Two rules make it read as cinematography rather than as a screensaver:
// - a cut is a hard change of subject, so shot sizes alternate (a close
// answered by a wide reads as two shots of one place);
// - the subject is chosen from recent ACTIVITY, not from the current state,
// because the interesting thing is the change, not the crowd.
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const smooth = (t) => t * t * (3 - 2 * t);
export class Camera {
constructor(song, world, { fps = 30, width = 640, height = 360 } = {}) {
this.song = song;
this.world = world;
this.fps = fps;
this.w = width;
this.h = height;
this.cx = 0.5;
this.cy = 0.5;
this.zoom = 0.9;
this.baseZoom = 0.9;
this.shot = 0;
this.shotStart = 0;
this.shotLen = 5;
this.nextCut = 0;
this.dir = 1;
this.prev = { x: 0.5, y: 0.5 };
this.target = { x: 0.5, y: 0.5 };
this.cues = new Set();
for (const o of song.analysis.onsets.all) {
if (o.strength > 0.72) this.cues.add(o.frame);
}
this.cut();
}
lawAt(frame) {
const t = frame / this.fps;
const laws = this.song.laws;
for (let i = laws.length - 1; i >= 0; i--) if (t >= laws[i].start) return laws[i];
return laws[0];
}
/** Where is the world actually doing something, weighted away from the last subject? */
pickFocus(zoom) {
const act = this.world.activity;
const n = 32;
const half = Math.min(0.5, 0.5 / zoom);
const lo = half + 0.02;
const hi = 1 - half - 0.02;
let best = -1;
let bx = 0.5;
let by = 0.5;
let maxAct = 1e-9;
for (let i = 0; i < act.length; i++) maxAct = Math.max(maxAct, act[i]);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const wx = (x + 0.5) / n;
const wy = (y + 0.5) / n;
if (wx < lo || wx > hi || wy < lo || wy > hi) continue;
const a = act[y * n + x] / maxAct;
if (a < 0.08) continue;
const d = Math.hypot(wx - this.prev.x, wy - this.prev.y);
// Novel subject, real activity, not pinned to the frame edge.
const score = a * (0.35 + 0.65 * Math.min(1, d * 2.4));
if (score > best) {
best = score;
bx = wx;
by = wy;
}
}
}
return { x: bx, y: by, value: maxAct };
}
activityCoverage() {
const act = this.world.activity;
let max = 1e-9;
for (let i = 0; i < act.length; i++) max = Math.max(max, act[i]);
let n = 0;
for (let i = 0; i < act.length; i++) if (act[i] > max * 0.12) n++;
return n / act.length;
}
cut() {
const frame = Math.max(0, this.world.frame);
const law = this.lawAt(frame);
const energy = law.energy ?? 0.5;
// Shot size: the macro world IS the shot. We hang up high so the whole
// map — every culture and the roads between them — is in frame, and
// only dip toward a knot on the rare close beat.
const coverage = this.activityCoverage();
let zoom = 0.92 + (1 - coverage) * 0.35;
if (this.shot % 3 === 2) zoom *= 0.88;
else if (this.shot % 3 === 1) zoom *= 1.08;
// The world is a map, not a Petri dish: stay high enough that
// territories and their fronts read.
zoom = clamp(zoom, 0.62, 1.08);
const focus = this.pickFocus(zoom);
this.target = { x: focus.x, y: focus.y };
this.cx = focus.x;
this.cy = focus.y;
this.zoom = zoom;
this.baseZoom = zoom;
this.dir = this.shot % 2 === 0 ? 1 : -1;
this.shotLen = clamp((3.6 + 3.4 * (1 - energy)) * (law.kind === 'breakdown' ? 1.25 : 1), 2.4, 9);
this.shotStart = frame;
this.nextCut = frame + Math.round(this.shotLen * this.fps);
this.prev = { x: focus.x, y: focus.y };
this.shot++;
}
update(frame) {
const age = frame - this.shotStart;
const law = this.lawAt(frame);
const cue = this.cues.has(frame);
if (frame >= this.nextCut || (cue && age > this.fps * 2.0 && (law.kind === 'drop' || law.kind === 'build'))) {
this.cut();
}
const t = clamp((frame - this.shotStart) / Math.max(1, this.nextCut - this.shotStart), 0, 1);
const e = smooth(t);
// A slow drift across the subject, in frame fractions rather than world
// units: the push must feel the same at a wide shot and a close-up.
this.cx = this.target.x + (this.dir * 0.030 * e) / this.zoom;
this.cy = this.target.y - (this.dir * 0.016 * e) / this.zoom;
this.zoom = this.baseZoom * (1 + 0.10 * e);
this.clampToWorld();
}
clampToWorld() {
// Zoom < 1 means the whole world fits; there is nothing to clamp to,
// so hold the centre. Otherwise bound the lens inside the dish.
const half = Math.min(0.5, 0.5 / this.zoom);
this.cx = clamp(this.cx, half, 1 - half);
this.cy = clamp(this.cy, half, 1 - half);
}
transform() {
return { cx: this.cx, cy: this.cy, zoom: this.zoom };
}
}
+122
View File
@@ -0,0 +1,122 @@
// WebAudio transport for the demo.
//
// The track is played from the exact mono PCM the analysis ran on, so the
// clock the world follows is the clock of the song itself. Playback position
// is derived from the AudioContext clock (currentTime), not from a timer, so
// the rings land on the hits even when the UI thread is busy rendering.
//
// The position is the only "wall clock" in the page, and it is deliberately
// not fed into the simulation: the world is a pure function of (frame, seed).
// The audio clock only chooses which frame the world should have reached.
export class AudioTransport {
/**
* @param {Float32Array} pcm mono samples
* @param {number} sampleRate
* @param {{ onEnd?: () => void }} [opts]
*/
constructor(pcm, sampleRate, { onEnd = () => {} } = {}) {
this.pcm = pcm;
this.sampleRate = sampleRate;
this.onEnd = onEnd;
this.ctx = null;
this.buffer = null;
this.gain = null;
this.src = null;
this.active = false;
this.when = 0; // ctx.currentTime at which the current src started
this.offset = 0; // seconds into the track the current src represents
this.volume = 0.9;
}
get duration() {
return this.buffer ? this.buffer.duration : this.pcm.length / this.sampleRate;
}
/** Lazily created inside a user gesture; creating it is policy-proof. */
ensure() {
if (this.ctx) return this.ctx;
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this.buffer = this.ctx.createBuffer(1, this.pcm.length, this.sampleRate);
this.buffer.copyToChannel(this.pcm, 0);
this.gain = this.ctx.createGain();
this.gain.gain.value = this.volume;
this.gain.connect(this.ctx.destination);
return this.ctx;
}
/** Seconds into the track, using the audio clock while playing. */
position() {
if (!this.active || !this.ctx) return this.offset;
return Math.min(this.duration, this.offset + Math.max(0, this.ctx.currentTime - this.when));
}
async play() {
const ctx = this.ensure();
if (ctx.state === 'suspended') await ctx.resume();
this.stopSource();
const src = ctx.createBufferSource();
src.buffer = this.buffer;
src.connect(this.gain);
const when = ctx.currentTime + 0.025;
this.src = src;
this.when = when;
this.active = true;
src.onended = () => {
// Only a natural finish ends the track; our own stopSource() has
// already cleared this.src, so a manual stop never reaches here.
if (this.active && this.src === src) {
this.active = false;
this.src = null;
this.offset = this.duration;
this.onEnd();
}
};
src.start(when, this.offset);
}
pause() {
if (!this.active) return;
this.offset = this.position();
this.stopSource();
this.active = false;
}
seek(seconds) {
const target = Math.max(0, Math.min(this.duration, seconds));
const wasActive = this.active;
if (wasActive) {
this.active = false;
this.stopSource();
}
this.offset = target;
if (wasActive) this.play();
}
setVolume(v) {
this.volume = Math.max(0, Math.min(1, v));
if (this.gain) this.gain.gain.value = this.volume;
}
stopSource() {
if (this.src) {
try {
this.src.onended = null;
this.src.stop();
} catch {
/* already stopped */
}
this.src = null;
}
}
dispose() {
this.stopSource();
this.ctx?.close();
this.ctx = null;
this.buffer = null;
}
}
+62
View File
@@ -0,0 +1,62 @@
// Catalog loading + display helpers for the demo page.
// The catalog itself is computed by the vite plugin (node-side analysis of the
// exact same pipeline the browser uses), so the library card and the player
// can never disagree about a song's shape.
const LAW_COLORS = {
intro: '#7d8fa3',
build: '#d8a24a',
drop: '#e2434b',
breakdown: '#4a8fe2',
sustain: '#3fbf9f',
outro: '#9a7fd6',
};
export function fetchCatalog() {
return fetch('/songs/catalog.json').then((r) => {
if (!r.ok) throw new Error(`catalog unavailable (${r.status})`);
return r.json();
});
}
export function lawColor(kind) {
return LAW_COLORS[kind] || '#5f6673';
}
/** A CSS accent (hue/sat from the track's palette, so the page wears the song). */
export function accentFor(entry) {
const hue = Number(entry.hue) || 0.6;
const sat = Number(entry.sat) || 0.6;
return `hsl(${Math.round(hue * 360)}, ${Math.round(Math.min(1, sat) * 100)}%, 58%)`;
}
export function fmtTime(s) {
if (!Number.isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const ss = String(Math.floor(s % 60)).padStart(2, '0');
return `${m}:${ss}`;
}
/** A one-line natural-language summary of what the track is. */
export function tagsFor(entry) {
const q = entry.summary;
const bits = [];
if (q.bpm) bits.push(`${Math.round(q.bpm)} BPM`);
bits.push(q.centroid > 0.5 ? 'bright' : 'dark');
bits.push(q.flatness < 0.12 ? 'tonal' : q.flatness < 0.4 ? 'grained' : 'noisy');
bits.push(q.beatStrength > 0.5 ? 'pulsing' : 'beatless');
bits.push(`${entry.laws.length} ${entry.laws.length === 1 ? 'part' : 'parts'}`);
return bits.join(' · ');
}
/** Read the URL's song/seed/res and give safe defaults for the rest. */
export function parseQuery() {
const p = new URLSearchParams(location.search);
const res = ['640x360', '960x540', '1280x720'].includes(p.get('res')) ? p.get('res') : '640x360';
return { song: p.get('song') || 'drone', seed: parseSeed(p.get('seed')), res };
}
function parseSeed(v) {
const n = parseInt(v ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 12345;
}
+501
View File
@@ -0,0 +1,501 @@
// metabolism — demo player
//
// The page is a thin shell over the real pipeline: a song is decoded with the
// exact decodeWav/analyse/buildSong the tools use, the world steps inside the
// same Run, and the audio clock only decides which frame the world should have
// reached. Nothing in the simulation reads the clock or a RNG.
//
// boot() -> load catalog, pick song from URL, start rAF loop
// selectSong(id) -> fetch/decode/analyse/build, grow to frame 0
// rAF loop (playing) -> target frame from audio clock -> step -> render
// seek/seed/res -> forward stepping is free; a backwards seek re-grows
// from frame 0 (the world and camera are stateful)
import { buildSong } from '../song/song.js';
import { deserializeAnalysis } from '../song/serialize.js';
import { decodeWav, mono } from '../audio/wav.js';
import { analyse } from '../audio/analyse.js';
import { FPS, Player } from './player.js';
import { AudioTransport } from './audio.js';
import { fetchCatalog, fmtTime, lawColor, accentFor, tagsFor, parseQuery } from './catalog.js';
const $ = (id) => document.getElementById(id);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const els = {
songs: $('songs'),
libCount: $('lib-count'),
view: $('view'),
overlay: $('overlay'),
caption: $('caption'),
play: $('play'),
seek: $('seek'),
fill: $('fill'),
ticks: $('ticks'),
t0: $('t0'),
t1: $('t1'),
law: $('law'),
shot: $('shot'),
hud: $('hud'),
seed: $('seed'),
res: $('res'),
volume: $('volume'),
};
const state = {
catalog: null,
byId: new Map(),
currentId: null,
cur: null, // { entry, song, analysis, player, audio }
seed: 12345,
res: '640x360',
playing: false,
loading: false,
fps: null, // achieved film rate, measured by the circuit breaker
lagS: null, // seconds the picture runs behind the audio clock
};
// ---- audio pipeline (browser side of the shared pipeline) ------------------
function fetchWav(id) {
return fetch(`/songs/${id}.wav`).then((r) => {
if (!r.ok) throw new Error(`no such song: ${id} (${r.status})`);
return r.arrayBuffer();
});
}
// Analysis depends only on the song (not the seed), so cache it; it is tiny
// (~a few hundred KB). The decoded PCM (21 MB for 120 s) is kept for one slot
// so flipping seed/res of the current song is instant. When `tools/prepare.js`
// has written a prepared artifact (out/prepared/<id>.json), the browser loads
// that instead of re-analysing the WAV — viewing skips the expensive pass.
const analysisCache = new Map();
let pcmSlot = null; // { id, pcm, sampleRate }
async function getAnalysis(id) {
const cached = analysisCache.get(id);
if (cached) return cached;
let analysis = null;
try {
const r = await fetch(`/prepared/${id}.json`);
if (r.ok) analysis = deserializeAnalysis(await r.json());
} catch { /* fall through to live analysis */ }
if (!analysis) {
analysis = analyse(pcmSlot.pcm, pcmSlot.sampleRate, { fps: FPS });
}
analysisCache.set(id, analysis);
return analysis;
}
async function loadSongData(id, seed, res) {
const [w, h] = res.split('x').map(Number);
if (!pcmSlot || pcmSlot.id !== id) {
pcmSlot = await fetchWav(id).then((ab) => {
const wav = decodeWav(ab);
if (!wav) throw new Error(`could not decode WAV: ${id}`);
return { id, pcm: mono(wav.channels), sampleRate: wav.sampleRate };
});
}
const analysis = await getAnalysis(id);
const song = buildSong(analysis, seed);
const player = new Player(els.view, w, h);
player.build(song);
await player.warm(); // the pool answers its boot acks before the show begins
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
return { entry: state.byId.get(id), song, analysis, player, audio };
}
// ---- song selection --------------------------------------------------------
async function selectSong(id, { seed = state.seed, res = state.res, restartAt = 0, push = true } = {}) {
if (state.loading) return;
const entry = state.byId.get(id);
if (!entry) return;
state.loading = true;
const carryPos = restartAt ?? state.cur?.audio.position() ?? 0;
pauseCurrent();
setCards(id);
setOverlay(`<span class="spinner"></span>warming the world…<small>${id} · seed ${seed}</small>`);
try {
const t0 = performance.now();
const ready = await loadSongData(id, seed, res);
const old = state.cur;
state.currentId = id;
state.seed = seed;
state.res = res;
state.cur = ready;
breakerReset();
// The new load owns the current audio; free the old context and its
// PCM buffer copy. (UI bookkeeping only — the sim never allocates here.)
if (old && old.audio !== ready.audio) old.audio.dispose();
ready.player.seekTo(carryPos);
ready.audio.seek(carryPos);
ready.player.renderCurrent();
els.seed.value = String(seed);
els.res.value = res;
applyAccent(ready.song);
setCaption(ready.entry);
buildTicks();
updateSeekUI(carryPos);
updateHUD();
setOverlay(null);
document.title = `metabolism — ${ready.entry.name}`;
state.loading = false;
if (push) syncUrl();
console.info(`[metabolism] ${id} · seed ${seed} · ${Math.round(performance.now() - t0)} ms to grow to ${fmtTime(carryPos)}`);
} catch (err) {
console.error(err);
setOverlay(`<b>could not grow “${id}”</b><small>${err.message}</small>`);
state.loading = false;
}
}
function pauseCurrent() {
if (state.cur) state.cur.audio.pause();
state.playing = false;
updatePlayButton();
}
// ---- transport -------------------------------------------------------------
async function togglePlay() {
const c = state.cur;
if (!c || state.loading) return;
if (state.playing) {
c.audio.pause();
state.playing = false;
updatePlayButton();
return;
}
if (c.audio.position() >= c.analysis.duration - 0.05) {
c.audio.seek(0);
c.player.seekTo(0);
c.player.renderCurrent();
updateSeekUI(0);
}
state.playing = true;
updatePlayButton();
setOverlay(null);
breakerReset(); // warm-up: a fresh world gets a fresh measurement
await c.audio.play();
}
function handleEnded() {
state.playing = false;
updatePlayButton();
updateSeekUI(state.cur.audio.position());
setOverlay(`<b>· end ·</b><small>that world is done — press play to grow it again</small>`);
}
function doSeek(seconds) {
const c = state.cur;
if (!c) return;
breakerReset(); // a re-grow runs between ticks; never measure it
const sec = clamp(seconds, 0, c.analysis.duration);
c.audio.seek(sec);
c.player.seekTo(sec);
c.player.renderCurrent();
updateSeekUI(sec);
updateHUD();
}
// ---- render loop -----------------------------------------------------------
// The circuit breaker. Growing the world must beat the audio clock: the loop
// steps to wherever the audio is NOW, so once per-frame cost exceeds the frame
// budget the deficit grows every tick and the page just thrashes. Measured is
// the ACHIEVED film rate — world frames grown per wall second (a catch-up tick
// that steps 3 frames counts as 3) — over rolling windows. Sustained below
// BREAKER_FPS, the show stops instead of hammering.
const BREAKER_FPS = 25; // the minimum sustained film rate we accept
const BREAKER_WIN = 2.0; // seconds per measurement window
const BREAKER_STRIKES = 2; // consecutive slow windows before we stop
const BREAKER_LAG = 30; // frames behind the audio clock that count as stranded
const BREAKER_LAG_HOLD = 750; // ms of stranding before we stop
const breaker = { frames: 0, dt: 0, last: 0, strikes: 0, lagSince: 0 };
function breakerReset() {
breaker.frames = 0;
breaker.dt = 0;
breaker.last = 0;
breaker.strikes = 0;
breaker.lagSince = 0;
state.fps = null;
state.lagS = null;
}
function tripBreaker(why) {
console.warn('[metabolism] circuit breaker:', JSON.stringify(why));
breakerReset();
pauseCurrent();
const detail = why.lagS != null
? `the picture fell ${why.lagS.toFixed(1)}s behind the song — playback stopped`
: `sustained ${Math.round(why.fps)} fps < ${BREAKER_FPS} — playback stopped`;
setOverlay(
`<b>the world outgrew this machine</b>` +
`<small>${detail}. Try a lower resolution (res) or another seed.</small>`
);
}
function breakerTick(steps, behind) {
const now = performance.now();
// The lag trip: a rate that still clears BREAKER_FPS but never reaches
// the audio clock means the picture drifts (the deficit grows every
// tick) — that is what chugging looks like when the rate looks fine.
if (behind >= BREAKER_LAG) {
if (!breaker.lagSince) breaker.lagSince = now;
else if (now - breaker.lagSince >= BREAKER_LAG_HOLD) { tripBreaker({ lagS: behind / FPS }); return; }
} else {
breaker.lagSince = 0;
state.lagS = behind > 0 ? behind / FPS : null;
}
if (!breaker.last) { breaker.last = now; return; }
breaker.dt += (now - breaker.last) / 1000;
breaker.last = now;
breaker.frames += Math.max(0, steps);
if (breaker.dt < BREAKER_WIN) return;
const fps = breaker.frames / breaker.dt;
breaker.frames = 0;
breaker.dt = 0;
state.fps = fps;
if (fps >= BREAKER_FPS - 1e-6) { // epsilon: float noise at the threshold
breaker.strikes = 0;
return;
}
if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker({ fps });
}
let hudTick = 0;
function loop() {
const c = state.cur;
if (c && state.playing && !state.loading) {
const pos = c.audio.position();
const target = clamp(Math.floor(pos * FPS), 0, c.analysis.frames - 1);
const before = c.player.run.world.frame;
c.player.stepTo(target);
c.player.renderCurrent();
breakerTick(target - before, Math.max(0, target - c.player.run.world.frame));
updateSeekUI(pos);
if (++hudTick % 4 === 0) updateHUD();
} else {
breaker.last = 0; // no wall time accumulates while paused
}
requestAnimationFrame(loop);
}
// ---- UI updates ------------------------------------------------------------
function updateSeekUI(pos) {
const c = state.cur;
if (!c) return;
const pct = clamp((pos / c.analysis.duration) * 100, 0, 100);
els.fill.style.width = `${pct}%`;
els.seek.value = Math.round(pct * 10);
els.t0.textContent = fmtTime(pos);
els.t1.textContent = fmtTime(c.analysis.duration);
}
function updatePlayButton() {
els.play.textContent = state.playing ? '❚❚' : '▶';
}
function setCaption(entry) {
els.caption.innerHTML =
`<b>${entry.name}</b> — ${entry.species} · seed ${state.seed}<br>` +
`<span class="muted">${tagsFor(entry)}</span>`;
}
function setCards(activeId) {
for (const li of els.songs.children) li.classList.toggle('active', li.dataset.id === activeId);
}
function applyAccent(song) {
const { hue, sat } = song.palette;
document.documentElement.style.setProperty(
'--accent',
`hsl(${Math.round(hue * 360)}, ${Math.round(Math.min(1, sat) * 100)}%, 58%)`
);
}
function buildTicks() {
const c = state.cur;
els.ticks.innerHTML = '';
if (!c) return;
const D = c.analysis.duration;
for (const l of c.song.laws.slice(1)) {
const t = document.createElement('span');
t.className = 'tick';
t.style.left = `${(l.start / D) * 100}%`;
t.style.background = lawColor(l.kind);
t.title = `${l.kind} @ ${fmtTime(l.start)}`;
els.ticks.appendChild(t);
}
}
function setLaw() {
const c = state.cur;
if (!c) return;
const t = c.audio.position();
const laws = c.song.laws;
let i = 0;
for (let k = 0; k < laws.length; k++) if (t >= laws[k].start) i = k;
const kind = laws[i].kind;
els.law.textContent = `${kind} · section ${i + 1}/${laws.length}`;
els.law.style.color = lawColor(kind);
els.law.style.borderColor = lawColor(kind);
}
function updateHUD() {
const c = state.cur;
if (!c) return;
const w = c.player.run.world;
const s = w.stats();
els.hud.textContent =
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` +
(state.fps != null ? ` · ${Math.round(state.fps)} fps` : '') +
(state.lagS != null && state.lagS >= 0.5 ? ` · ${(state.lagS).toFixed(1)}s behind` : '');
const cam = c.player.run.camera;
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
setLaw();
}
function setOverlay(html) {
if (!html) {
els.overlay.hidden = true;
return;
}
els.overlay.hidden = false;
els.overlay.innerHTML = html;
}
function syncUrl() {
const p = new URLSearchParams();
p.set('song', state.currentId);
p.set('seed', String(state.seed));
p.set('res', state.res);
history.replaceState(null, '', `?${p}`);
}
// ---- library ---------------------------------------------------------------
function renderLibrary(catalog) {
els.songs.innerHTML = '';
state.byId = new Map();
for (const entry of catalog.songs) {
state.byId.set(entry.name, entry);
const li = document.createElement('li');
li.className = 'song';
li.dataset.id = entry.name;
const swatch = document.createElement('span');
swatch.className = 'swatch';
swatch.style.background = accentFor(entry);
const meta = document.createElement('span');
meta.className = 'smeta';
const name = document.createElement('span');
name.className = 'sname';
name.textContent = entry.name;
const species = document.createElement('span');
species.className = 'sspecies';
species.textContent = entry.species;
meta.append(name, species);
const tags = document.createElement('span');
tags.className = 'stags';
tags.textContent = tagsFor(entry);
li.append(swatch, meta, tags);
li.addEventListener('click', () => selectSong(entry.name));
els.songs.appendChild(li);
}
}
// ---- controls --------------------------------------------------------------
els.play.addEventListener('click', togglePlay);
let pausedByDrag = false;
els.seek.addEventListener('pointerdown', () => {
const c = state.cur;
if (!c) return;
pausedByDrag = state.playing;
if (pausedByDrag) {
c.audio.pause();
state.playing = false;
updatePlayButton();
}
});
els.seek.addEventListener('change', () => {
const c = state.cur;
if (!c) return;
const sec = (els.seek.valueAsNumber / 1000) * c.analysis.duration;
doSeek(sec);
if (pausedByDrag) {
pausedByDrag = false;
state.playing = true;
updatePlayButton();
c.audio.play();
}
});
els.seed.addEventListener('change', () => {
const v = parseInt(els.seed.value, 10);
const seed = Number.isFinite(v) && v > 0 ? v >>> 0 : 12345;
els.seed.value = String(seed);
selectSong(state.currentId, { seed, res: state.res, restartAt: state.cur?.audio.position() ?? 0 });
});
els.res.addEventListener('change', () => {
selectSong(state.currentId, { seed: state.seed, res: els.res.value, restartAt: state.cur?.audio.position() ?? 0 });
});
els.volume.addEventListener('input', () => {
state.cur?.audio.setVolume(els.volume.valueAsNumber / 100);
});
window.addEventListener('keydown', (e) => {
if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
if (e.code === 'Space') {
e.preventDefault();
togglePlay();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
doSeek((state.cur?.audio.position() ?? 0) + 5);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
doSeek((state.cur?.audio.position() ?? 0) - 5);
}
});
// ---- boot ------------------------------------------------------------------
async function boot() {
const q = parseQuery();
state.seed = q.seed;
state.res = q.res;
els.seed.value = String(q.seed);
els.res.value = q.res;
els.volume.value = '90';
els.libCount.textContent = '…';
try {
const catalog = await fetchCatalog();
state.catalog = catalog;
renderLibrary(catalog);
els.libCount.textContent = `${catalog.songs.length} tracks`;
await selectSong(q.song, { seed: q.seed, res: q.res, push: false });
} catch (err) {
console.error(err);
els.libCount.textContent = 'unavailable';
setOverlay(`<b>no library</b><small>is the dev server serving <code>../flow-state/test/songs</code>? — ${err.message}</small>`);
}
requestAnimationFrame(loop);
}
// Debug handle: the live world is reachable for poking, but the sim itself is
// clock-free; touching it is how the tools inspect it too.
window.metabolism = { state, get run() { return state.cur?.player.run ?? null; } };
boot();

Some files were not shown because too many files have changed in this diff Show More