Compare commits
49
Commits
a9b028b0bf
...
23d3696adc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d3696adc | ||
|
|
53a973c34e | ||
|
|
cfc0bddb1f | ||
|
|
d6e97a2b52 | ||
|
|
3a72525ccb | ||
|
|
476fa1833a | ||
|
|
1d497e556e | ||
|
|
8abefb16b7 | ||
|
|
f939351689 | ||
|
|
16f4b4a805 | ||
|
|
13b0939221 | ||
|
|
6a2af2e45f | ||
|
|
c96e810d44 | ||
|
|
3df4b3545b | ||
|
|
56919ed819 | ||
|
|
9fc80c38eb | ||
|
|
18f1295984 | ||
|
|
215fe98c06 | ||
|
|
cf9b4c111a | ||
|
|
3a3022327c | ||
|
|
ac6654b0dd | ||
|
|
89f295532a | ||
|
|
2704831047 | ||
|
|
a0665d75df | ||
|
|
8870f1edc9 | ||
|
|
4d05250294 | ||
|
|
cc8888077f | ||
|
|
6c7e314a3e | ||
|
|
cd7dfcd63d | ||
|
|
a99a7cbe49 | ||
|
|
6e70f95d4d | ||
|
|
3b65ea905c | ||
|
|
fd0cbe0553 | ||
|
|
ce79ccf387 | ||
|
|
822f4b12ef | ||
|
|
4b0f27da9a | ||
|
|
9c768f13d1 | ||
|
|
46fab512df | ||
|
|
b1b6431eeb | ||
|
|
49914fd4ad | ||
|
|
b0989169e2 | ||
|
|
e0e619af85 | ||
|
|
e2f56e3ce4 | ||
|
|
82ab88e6b0 | ||
|
|
e8c04e7798 | ||
|
|
a0eaf2ef95 | ||
|
|
904ae09486 | ||
|
|
b7c3537fcb | ||
|
|
7736390f9c |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run lint:scenes *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +2,8 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.vite
|
.vite
|
||||||
out
|
out
|
||||||
|
.env.local
|
||||||
|
logs/
|
||||||
|
|
||||||
# Scratch probes: one-off harnesses for debugging decode/encode/mux issues.
|
# Scratch probes: one-off harnesses for debugging decode/encode/mux issues.
|
||||||
# They are throwaway by nature and were committed once by accident.
|
# They are throwaway by nature and were committed once by accident.
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -33,6 +33,22 @@
|
|||||||
pre { padding: 8px 10px; overflow-x: auto; margin: 6px 0 0; }
|
pre { padding: 8px 10px; overflow-x: auto; margin: 6px 0 0; }
|
||||||
.note { color: #6b7280; font-size: 12px; max-width: 80ch; }
|
.note { color: #6b7280; font-size: 12px; max-width: 80ch; }
|
||||||
.warn { color: #eab308; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -43,6 +59,48 @@
|
|||||||
many.
|
many.
|
||||||
</p>
|
</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>
|
<h2>Looking at output</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card fast">
|
<div class="card fast">
|
||||||
@@ -78,6 +136,13 @@
|
|||||||
<a href="/filmstrip.html?song=centre,ember">?song=</a> just these ·
|
<a href="/filmstrip.html?song=centre,ember">?song=</a> just these ·
|
||||||
<code>EPIC-4.md</code> for the arc it is checking</div>
|
<code>EPIC-4.md</code> for the arc it is checking</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card fast">
|
||||||
|
<h3><a href="/venue-gallery.html">venue gallery</a></h3>
|
||||||
|
<p>Venue × 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">
|
<div class="card fast">
|
||||||
<h3><a href="/">the app</a></h3>
|
<h3><a href="/">the app</a></h3>
|
||||||
<p>Drop a track and watch it. <code>test/songs/*.wav</code> holds the
|
<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
|
session over a file called <code>clicktrack.js</code> — or a dev server
|
||||||
caught mid-restart.
|
caught mid-restart.
|
||||||
</p>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
@@ -39,6 +39,15 @@
|
|||||||
.row.churn { border-color: #eab308; }
|
.row.churn { border-color: #eab308; }
|
||||||
.row.arc { border-color: #22c55e; }
|
.row.arc { border-color: #22c55e; }
|
||||||
.row.broken { border-color: #a855f7; background: #150e1c; }
|
.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; }
|
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
.name { font-size: 14px; color: #e6eaf2; }
|
.name { font-size: 14px; color: #e6eaf2; }
|
||||||
.meta { color: #6b7280; font-size: 11px; }
|
.meta { color: #6b7280; font-size: 11px; }
|
||||||
@@ -54,6 +63,8 @@
|
|||||||
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
|
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
|
||||||
.strip figure { margin: 0; flex: 0 0 auto; width: 168px; }
|
.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; }
|
.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 figcaption { font-size: 10px; color: #565f70; margin-top: 3px; line-height: 1.35; }
|
||||||
.strip .t { color: #8b94a7; }
|
.strip .t { color: #8b94a7; }
|
||||||
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -79,9 +90,11 @@
|
|||||||
<div class="bar" id="bar">
|
<div class="bar" id="bar">
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
<button id="rebuild">rebuild</button>
|
<button id="rebuild">rebuild</button>
|
||||||
|
<select id="venueSel" title="pin one venue in every row, or census"></select>
|
||||||
<select id="sort">
|
<select id="sort">
|
||||||
<option value="direction">sort: least direction first</option>
|
<option value="direction">sort: least direction first</option>
|
||||||
<option value="drift">sort: least drift 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="name">sort: bank order</option>
|
||||||
<option value="plot">sort: by plot</option>
|
<option value="plot">sort: by plot</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -92,9 +105,11 @@
|
|||||||
<div id="out"></div>
|
<div id="out"></div>
|
||||||
|
|
||||||
<script type="module">
|
<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 { blit } from '/src/checks/blit.js';
|
||||||
import { hashString } from '/src/engine/rng.js';
|
import { hashString } from '/src/engine/rng.js';
|
||||||
|
import { previewLink } from '/src/ui/preview.js';
|
||||||
import {
|
import {
|
||||||
sourceFingerprint, loadGallery, saveGallery,
|
sourceFingerprint, loadGallery, saveGallery,
|
||||||
pixelsToBlob, blobToCanvas,
|
pixelsToBlob, blobToCanvas,
|
||||||
@@ -104,6 +119,8 @@ const params = new URLSearchParams(location.search);
|
|||||||
const every = Number(params.get('every')) || DEFAULT_EVERY;
|
const every = Number(params.get('every')) || DEFAULT_EVERY;
|
||||||
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
|
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
|
||||||
const only = params.get('song') ? params.get('song').split(',') : null;
|
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;
|
document.getElementById('everyLabel').textContent = every;
|
||||||
|
|
||||||
@@ -111,23 +128,58 @@ const bar = document.getElementById('bar');
|
|||||||
const out = document.getElementById('out');
|
const out = document.getElementById('out');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
const sortSel = document.getElementById('sort');
|
const sortSel = document.getElementById('sort');
|
||||||
|
const venueSel = document.getElementById('venueSel');
|
||||||
const progress = document.querySelector('#progress i');
|
const progress = document.querySelector('#progress i');
|
||||||
const rebuildBtn = document.getElementById('rebuild');
|
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 = [];
|
let rows = [];
|
||||||
|
|
||||||
// Read against the arcless reference the variety report prints: around zero is
|
// 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.
|
// a video that is as unlike itself after ten seconds as after four minutes.
|
||||||
const grade = (row) => (row.error ? 'broken'
|
const AGIT_CEILING = 0.70;
|
||||||
: row.direction < 0.15 ? 'static'
|
const agitClass = (v) => (v == null ? 'low' : v >= AGIT_CEILING ? 'high' : v >= 0.50 ? 'mid' : 'low');
|
||||||
: row.direction < 0.45 ? 'churn' : 'arc');
|
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
|
// 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
|
// 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 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.
|
// the strip measured rather than a fresh draw that happens to share a title.
|
||||||
const watchLink = (name) =>
|
// The venue flag and pin ride along too, so a venue strip replays its room.
|
||||||
`/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
|
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.
|
* One song's row.
|
||||||
@@ -140,13 +192,19 @@ const watchLink = (name) =>
|
|||||||
function renderRow(row) {
|
function renderRow(row) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `row ${grade(row)}`;
|
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 = `
|
el.innerHTML = `
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="name">${row.name}</span>
|
<span class="name">${row.name}</span>
|
||||||
<a class="watch" href="${watchLink(row.name)}" target="_blank"
|
<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>
|
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="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>
|
<span class="story">${row.error ? `<span class="err">${row.error}</span>` : (row.storyLine || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="strip"></div>`;
|
<div class="strip"></div>`;
|
||||||
@@ -156,6 +214,8 @@ function renderRow(row) {
|
|||||||
if (shot.moment === 'climax') fig.className = 'climax';
|
if (shot.moment === 'climax') fig.className = 'climax';
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
fig.appendChild(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 cap = document.createElement('figcaption');
|
||||||
const mm = Math.floor(shot.time / 60);
|
const mm = Math.floor(shot.time / 60);
|
||||||
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
|
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
|
||||||
@@ -183,7 +243,8 @@ function draw() {
|
|||||||
const mode = sortSel.value;
|
const mode = sortSel.value;
|
||||||
const order = songNames();
|
const order = songNames();
|
||||||
const sorted = rows.slice().sort((a, b) => (
|
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 === 'drift' ? a.drift - b.drift
|
||||||
: mode === 'plot' ? ((a.story?.plot || '').localeCompare(b.story?.plot || '') || a.direction - b.direction)
|
: mode === 'plot' ? ((a.story?.plot || '').localeCompare(b.story?.plot || '') || a.direction - b.direction)
|
||||||
: a.direction - b.direction));
|
: a.direction - b.direction));
|
||||||
@@ -196,16 +257,22 @@ sortSel.addEventListener('change', draw);
|
|||||||
|
|
||||||
function summarise(built) {
|
function summarise(built) {
|
||||||
const flat = rows.filter((r) => !r.error && r.direction < 0.15).length;
|
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
|
const meanDir = rows.length
|
||||||
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
|
? 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 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()}` : '');
|
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cache key carries the sampling parameters as well as the source, so
|
// 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.
|
// 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() {
|
async function build() {
|
||||||
bar.classList.add('building');
|
bar.classList.add('building');
|
||||||
@@ -215,7 +282,7 @@ async function build() {
|
|||||||
|
|
||||||
const total = (only || songNames()).length;
|
const total = (only || songNames()).length;
|
||||||
const built = await buildFilmstrip({
|
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.
|
// 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
|
// 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
|
// failure — a strip whose frames could be shuffled — is visible in the
|
||||||
@@ -239,7 +306,7 @@ async function build() {
|
|||||||
...row,
|
...row,
|
||||||
frames: undefined,
|
frames: undefined,
|
||||||
shots: await Promise.all((row.frames || []).map(async (f) => ({
|
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,
|
tension: f.tension, journey: f.journey, moment: f.moment,
|
||||||
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
|
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
|
||||||
}))),
|
}))),
|
||||||
|
|||||||
+45
-2
@@ -44,6 +44,12 @@
|
|||||||
.row.thin { border-color: #eab308; }
|
.row.thin { border-color: #eab308; }
|
||||||
.row.good { border-color: #22c55e; }
|
.row.good { border-color: #22c55e; }
|
||||||
.row.broken { border-color: #a855f7; background: #150e1c; }
|
.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; }
|
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
.name { font-size: 14px; color: #e6eaf2; }
|
.name { font-size: 14px; color: #e6eaf2; }
|
||||||
.fam { color: #6b7280; }
|
.fam { color: #6b7280; }
|
||||||
@@ -132,6 +138,7 @@
|
|||||||
<option value="name">sort: by name</option>
|
<option value="name">sort: by name</option>
|
||||||
<option value="family">sort: by family</option>
|
<option value="family">sort: by family</option>
|
||||||
<option value="coverage">sort: sparsest first</option>
|
<option value="coverage">sort: sparsest first</option>
|
||||||
|
<option value="agitation">sort: most agitating first</option>
|
||||||
</select>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
<span id="sweepctl" hidden>
|
<span id="sweepctl" hidden>
|
||||||
@@ -155,8 +162,11 @@
|
|||||||
<script type="module">
|
<script type="module">
|
||||||
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
|
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
|
||||||
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.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.
|
// 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 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 {
|
import {
|
||||||
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
||||||
pixelsToBlob, blobToCanvas,
|
pixelsToBlob, blobToCanvas,
|
||||||
@@ -181,11 +191,13 @@ let contexts = [];
|
|||||||
// the eye can still find the flattest scenes in a sorted list without anyone
|
// 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.
|
// pretending a threshold decides whether a scene is allowed to exist.
|
||||||
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
|
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
|
||||||
|
const agitationGrade = (a) => (a >= agitationCeiling ? 'high' : a >= 0.50 ? 'mid' : 'low');
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
const mode = sortSel.value;
|
const mode = sortSel.value;
|
||||||
const sorted = rows.slice().sort((a, b) => (
|
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 === 'coverage' ? (a.coverage || 0) - (b.coverage || 0)
|
||||||
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
|
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
|
||||||
: a.variety - b.variety));
|
: a.variety - b.variety));
|
||||||
@@ -193,9 +205,13 @@ function draw() {
|
|||||||
out.innerHTML = '';
|
out.innerHTML = '';
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
const el = document.createElement('div');
|
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 || {})
|
const blocks = Object.entries(row.byBlock || {})
|
||||||
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
|
.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 = `
|
el.innerHTML = `
|
||||||
<div class="head">
|
<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>
|
<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>
|
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
|
||||||
${row.variety.toFixed(3)}
|
${row.variety.toFixed(3)}
|
||||||
</span>
|
</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>
|
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="thumbs"></div>`;
|
<div class="thumbs"></div>`;
|
||||||
@@ -222,6 +242,27 @@ function draw() {
|
|||||||
// 390 images and decoding them all synchronously stalls the page.
|
// 390 images and decoding them all synchronously stalls the page.
|
||||||
blobToCanvas(canvas, blob);
|
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);
|
out.appendChild(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +279,7 @@ function summarise(built) {
|
|||||||
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
|
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
|
||||||
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
|
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
|
||||||
(broken ? ` · ${broken} BROKEN` : '') +
|
(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` +
|
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
|
||||||
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
|
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
|
||||||
}
|
}
|
||||||
@@ -271,6 +313,7 @@ async function build(fingerprint) {
|
|||||||
role: row.module.role || 'stage', variety: row.variety,
|
role: row.module.role || 'stage', variety: row.variety,
|
||||||
coverage: row.coverage, surface: row.surface,
|
coverage: row.coverage, surface: row.surface,
|
||||||
byBlock: row.byBlock, error: row.error,
|
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(
|
blobs: await Promise.all(row.thumbs.map(
|
||||||
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
|
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
<button id="btn-reroll" title="New seed for the whole track">reroll</button>
|
<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-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-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-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-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>
|
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
|
||||||
|
|||||||
Generated
+142
-1
@@ -9,14 +9,80 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.112",
|
||||||
|
"ai": "^6.0.282",
|
||||||
"mp4-muxer": "^5.2.2",
|
"mp4-muxer": "^5.2.2",
|
||||||
"three": "^0.181.1"
|
"three": "^0.181.1",
|
||||||
|
"zod": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"vite": "^7.2.2"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"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": "^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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
@@ -842,6 +917,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/dom-webcodecs": {
|
||||||
"version": "0.1.18",
|
"version": "0.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
|
"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==",
|
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
@@ -903,6 +1011,15 @@
|
|||||||
"@esbuild/win32-x64": "0.28.1"
|
"@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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": "^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": {
|
"node_modules/mp4-muxer": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
|
||||||
@@ -1141,6 +1264,15 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
@@ -1215,6 +1347,15 @@
|
|||||||
"optional": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
"vite": "^7.2.2"
|
"vite": "^7.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.112",
|
||||||
|
"ai": "^6.0.282",
|
||||||
"mp4-muxer": "^5.2.2",
|
"mp4-muxer": "^5.2.2",
|
||||||
"three": "^0.181.1"
|
"three": "^0.181.1",
|
||||||
|
"zod": "^4.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-8
@@ -7,6 +7,8 @@ import { ArcDriver } from './look/ArcDriver.js';
|
|||||||
import { grainEnvelope } from './look/grain.js';
|
import { grainEnvelope } from './look/grain.js';
|
||||||
import { hashSamples } from './engine/rng.js';
|
import { hashSamples } from './engine/rng.js';
|
||||||
import { subjectOf } from './look/stack.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
|
* 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
|
* only so that watching a song reproduces the look they measured — the
|
||||||
* filmstrip seeds off the song's name, not off its samples.
|
* 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);
|
const report = (stage, p) => onProgress && onProgress(stage, p);
|
||||||
|
|
||||||
report('decoding', 0);
|
report('decoding', 0);
|
||||||
@@ -69,9 +71,18 @@ export class Show {
|
|||||||
|
|
||||||
report('look', 0.97);
|
report('look', 0.97);
|
||||||
const samples = monoSamples(audioBuffer);
|
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
|
this.setLook(seed === null
|
||||||
? generateLook(this.track, { samples })
|
? generateLook(this.track, { samples, venueEnabled: wantsVenue })
|
||||||
: generateLook(this.track, { seed }));
|
: 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.timeline.setDuration(this.track.duration);
|
||||||
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
||||||
@@ -97,7 +108,7 @@ export class Show {
|
|||||||
this.track = track;
|
this.track = track;
|
||||||
this.engine.timeline.setDuration(track.duration);
|
this.engine.timeline.setDuration(track.duration);
|
||||||
this.engine.setFeatureProvider(featureProviderFor(track));
|
this.engine.setFeatureProvider(featureProviderFor(track));
|
||||||
this.setLook(look || generateLook(track, { seed: 1 }));
|
this.setLook(look || generateLook(track, { seed: 1, venueEnabled: this._wantsVenue() }));
|
||||||
this.prewarm();
|
this.prewarm();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -111,6 +122,34 @@ export class Show {
|
|||||||
return this;
|
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) {
|
reroll(seed) {
|
||||||
this.setLook(rerollLook(this.look, this.track, seed));
|
this.setLook(rerollLook(this.look, this.track, seed));
|
||||||
this.prewarm();
|
this.prewarm();
|
||||||
@@ -144,6 +183,37 @@ export class Show {
|
|||||||
this.engine.setSize(width, height);
|
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. */
|
/** Fade in at the head and out at the tail; nothing starts or ends abruptly. */
|
||||||
_fadeAt(frame) {
|
_fadeAt(frame) {
|
||||||
const fadeFrames = FADE_SECONDS * this.fps;
|
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.
|
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
|
||||||
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
|
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
|
||||||
const pers = this.look.personality;
|
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({
|
this.engine.compositor.updateSharedCamera({
|
||||||
framing,
|
framing,
|
||||||
personality: pers,
|
personality: pers,
|
||||||
time: timeline.time,
|
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
|
// 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) {
|
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
|
* 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.
|
* 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 } = {}) {
|
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) {
|
if (frames > 0) {
|
||||||
this.warmUp(frame, frames);
|
this.warmUp(frame, frames);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,13 +16,102 @@ export async function decodeFile(file) {
|
|||||||
return decodeArrayBuffer(arrayBuffer);
|
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) {
|
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
|
// decodeAudioData detaches the buffer, so hand it a copy if the caller may
|
||||||
// still need the original bytes.
|
// still need the original bytes.
|
||||||
return await ctx.decodeAudioData(arrayBuffer.slice(0));
|
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. */
|
/** Interleaved mono mixdown. Used for the content hash that seeds the look. */
|
||||||
export function monoSamples(audioBuffer) {
|
export function monoSamples(audioBuffer) {
|
||||||
const channels = audioBuffer.numberOfChannels;
|
const channels = audioBuffer.numberOfChannels;
|
||||||
|
|||||||
@@ -26,7 +26,30 @@
|
|||||||
// These are test signals, not music. They exist to be measured.
|
// These are test signals, not music. They exist to be measured.
|
||||||
|
|
||||||
import { FeatureTrack } from './FeatureTrack.js';
|
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
|
* @typedef {object} SongSpec
|
||||||
@@ -88,9 +111,10 @@ export function song(name, { fps = 60, duration = DURATION } = {}) {
|
|||||||
const spec = SONGS.find((s) => s.name === name);
|
const spec = SONGS.find((s) => s.name === name);
|
||||||
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
||||||
|
|
||||||
const entry = {
|
// Analysed from the bank PCM, not the raw synth: peak gain, 16-bit
|
||||||
...spec,
|
// grid, mono — the exact samples test/songs/*.wav holds, so this track
|
||||||
track: FeatureTrack.fromAudioBuffer(synthesizeSong({
|
// is the file the app decodes rather than a near-twin of it. See bankPcm.
|
||||||
|
const synth = synthesizeSong({
|
||||||
bpm: spec.bpm,
|
bpm: spec.bpm,
|
||||||
duration,
|
duration,
|
||||||
arrangement: spec.arrangement,
|
arrangement: spec.arrangement,
|
||||||
@@ -100,8 +124,11 @@ export function song(name, { fps = 60, duration = DURATION } = {}) {
|
|||||||
dynamics: spec.dynamics,
|
dynamics: spec.dynamics,
|
||||||
transition: spec.transition ?? 0.5,
|
transition: spec.transition ?? 0.5,
|
||||||
seed: 1 + SONGS.indexOf(spec),
|
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);
|
cache.set(cacheKey, entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ import { Show, FADE_SECONDS } from '../Show.js';
|
|||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
import { song, SONGS } from '../audio/songbank.js';
|
import { song, SONGS } from '../audio/songbank.js';
|
||||||
import { hashString } from '../engine/rng.js';
|
import { hashString } from '../engine/rng.js';
|
||||||
|
import { pinVenueShell } from '../venues/pin.js';
|
||||||
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
||||||
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
|
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
|
||||||
import { describeStory } from '../look/Story.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 };
|
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
|
* 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.
|
* 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);
|
export const songNames = () => SONGS.map((s) => s.name);
|
||||||
|
|
||||||
@@ -67,12 +72,26 @@ function blockMean(byBlock) {
|
|||||||
*
|
*
|
||||||
* @returns {{name, frames: Array, drift: number, direction: number, look}}
|
* @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 });
|
const { track } = song(name, { duration });
|
||||||
// Seeded off the song's name, so a strip is stable between rebuilds and two
|
// Seeded off the song's name, so a strip is stable between rebuilds and two
|
||||||
// songs are not accidentally handed the same look.
|
// 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);
|
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 frames = [];
|
||||||
const descriptors = [];
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
frames,
|
frames,
|
||||||
|
look,
|
||||||
drift: mean(dists),
|
drift: mean(dists),
|
||||||
direction: directionOf(gaps, 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
|
// 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
|
// 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.
|
// come back on purpose. A recap should show up here and nowhere else.
|
||||||
@@ -161,7 +223,8 @@ function momentAt(story, index) {
|
|||||||
* times for nothing.
|
* times for nothing.
|
||||||
*/
|
*/
|
||||||
export async function buildFilmstrip({
|
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,
|
onSong = null, onSongStart = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const list = names && names.length ? names : songNames();
|
const list = names && names.length ? names : songNames();
|
||||||
@@ -179,9 +242,9 @@ export async function buildFilmstrip({
|
|||||||
}
|
}
|
||||||
let row;
|
let row;
|
||||||
try {
|
try {
|
||||||
row = renderSong(show, list[i], { duration, every });
|
row = renderSong(show, list[i], { duration, every, venueEnabled, venueSel });
|
||||||
} catch (err) {
|
} 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);
|
rows.push(row);
|
||||||
if (onSong) onSong(i + 1, list.length, row);
|
if (onSong) onSong(i + 1, list.length, row);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
|||||||
import { frameLuminance, frameVariance } from '../engine/hash.js';
|
import { frameLuminance, frameVariance } from '../engine/hash.js';
|
||||||
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
|
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
|
||||||
import { generateActor } from '../actors/ActorGenerator.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 };
|
const THUMB = { width: 256, height: 144 };
|
||||||
|
|
||||||
@@ -162,6 +164,108 @@ export function renderScene(engine, module, contexts) {
|
|||||||
const variance = frameVariance(thumbs[0]);
|
const variance = frameVariance(thumbs[0]);
|
||||||
const dead = lum < 0.002 || variance < 0.001;
|
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 {
|
return {
|
||||||
thumbs,
|
thumbs,
|
||||||
// The six per-context descriptors, so a caller can average them into a
|
// 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,
|
variety: pairs ? total / pairs : 0,
|
||||||
coverage: covered,
|
coverage: covered,
|
||||||
byBlock,
|
byBlock,
|
||||||
|
agitation,
|
||||||
|
flashPeak,
|
||||||
|
motionPeak,
|
||||||
|
motionMean,
|
||||||
|
structPeak,
|
||||||
|
structMean,
|
||||||
|
metrics,
|
||||||
error: dead
|
error: dead
|
||||||
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
|
? `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.'
|
'A failed shader compile scores 0.000 on every block; check the console for GLSL errors.'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { galleryContexts, renderScene, THUMB } from './gallery.js';
|
|||||||
import { readsHistory } from '../params/schema.js';
|
import { readsHistory } from '../params/schema.js';
|
||||||
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
|
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
|
||||||
import metadata from '../scenes/metadata.json';
|
import metadata from '../scenes/metadata.json';
|
||||||
|
import { METRIC_KEYS } from './scene-metrics.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What invalidates the measurements.
|
* 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/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/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/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/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/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/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/Personality.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
...import.meta.glob('/src/look/palette.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. */
|
/** The version of the measurement itself. Bump to force a refresh of everything. */
|
||||||
export const SCHEMA = 5;
|
export const SCHEMA = 7;
|
||||||
|
|
||||||
export function metricsFingerprint() {
|
export function metricsFingerprint() {
|
||||||
let h = 0x811c9dc5 >>> 0;
|
let h = 0x811c9dc5 >>> 0;
|
||||||
@@ -133,8 +142,18 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
|
|||||||
try {
|
try {
|
||||||
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
|
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
|
||||||
for (const module of list) {
|
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);
|
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] = {
|
out[module.name] = {
|
||||||
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
|
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
|
// 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),
|
variety: round(variety, 3),
|
||||||
blocks: Object.fromEntries(
|
blocks: Object.fromEntries(
|
||||||
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
|
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
|
// Declared, not measured, and carried here anyway: it is a fact
|
||||||
// about the scene that the composition rules read, and having
|
// about the scene that the composition rules read, and having
|
||||||
// every compositional input in one file is the point.
|
// every compositional input in one file is the point.
|
||||||
@@ -193,6 +222,15 @@ export function metadataDrift(fresh) {
|
|||||||
delta,
|
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]);
|
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { shiftPalette, paletteContrast } from '../look/palette.js';
|
|||||||
import { ArcDriver } from '../look/ArcDriver.js';
|
import { ArcDriver } from '../look/ArcDriver.js';
|
||||||
import { Engine } from '../engine/Engine.js';
|
import { Engine } from '../engine/Engine.js';
|
||||||
import { featureProviderFor } from '../audio/FeatureTrack.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 { Rng } from '../engine/rng.js';
|
||||||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||||||
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.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);
|
const end = at(t.duration * 0.95);
|
||||||
|
|
||||||
for (const item of axis) {
|
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 [lo, hi] = item.def.range;
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const a = start[item.name], m = mid[item.name], z = end[item.name];
|
const a = start[item.name], m = mid[item.name], z = end[item.name];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
|||||||
import { synthesizeSectioned } from '../audio/synth.js';
|
import { synthesizeSectioned } from '../audio/synth.js';
|
||||||
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
|
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
|
||||||
import { peakFlashRate } from '../engine/flash.js';
|
import { peakFlashRate } from '../engine/flash.js';
|
||||||
|
import { AGITATION_CEILING, agitationScore } from '../engine/agitation.js';
|
||||||
import { generatePersonality } from '../look/Personality.js';
|
import { generatePersonality } from '../look/Personality.js';
|
||||||
import { generateIdentity } from '../look/Identity.js';
|
import { generateIdentity } from '../look/Identity.js';
|
||||||
import { generateActor } from '../actors/ActorGenerator.js';
|
import { generateActor } from '../actors/ActorGenerator.js';
|
||||||
@@ -154,6 +155,28 @@ export function runSceneGate(name) {
|
|||||||
const rate = peakFlashRate(luminance, 60);
|
const rate = peakFlashRate(luminance, 60);
|
||||||
record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`);
|
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 -----------------------------------------------
|
// --- identity response -----------------------------------------------
|
||||||
// The migration gate. A scene that declares it consumes the cast must
|
// The migration gate. A scene that declares it consumes the cast must
|
||||||
// produce a DIFFERENT PICTURE when the song's cast changes — otherwise
|
// produce a DIFFERENT PICTURE when the song's cast changes — otherwise
|
||||||
|
|||||||
@@ -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',
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -64,6 +64,12 @@ export class Compositor {
|
|||||||
const bh = Math.max(1, Math.floor(h / 2));
|
const bh = Math.max(1, Math.floor(h / 2));
|
||||||
|
|
||||||
this.layerTarget = r.createTarget(w, h, { depth: true });
|
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.accumA = r.createTarget(w, h);
|
||||||
this.accumB = r.createTarget(w, h);
|
this.accumB = r.createTarget(w, h);
|
||||||
this.historyA = r.createTarget(w, h, { float: true });
|
this.historyA = r.createTarget(w, h, { float: true });
|
||||||
@@ -89,10 +95,25 @@ export class Compositor {
|
|||||||
this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height);
|
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 cam = this.sharedCamera;
|
||||||
const frame = framing || { scale: 1, shift: [0, 0] };
|
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 dolly = 4 / Math.max(frame.scale, 0.05);
|
||||||
const pCam = personality ? personality.camera : null;
|
const pCam = personality ? personality.camera : null;
|
||||||
if (pCam) {
|
if (pCam) {
|
||||||
@@ -114,6 +135,156 @@ export class Compositor {
|
|||||||
cam.updateMatrixWorld();
|
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() {
|
_buildMaterials() {
|
||||||
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
|
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
|
||||||
u_base: { value: null },
|
u_base: { value: null },
|
||||||
@@ -271,7 +442,7 @@ export class Compositor {
|
|||||||
reset() {
|
reset() {
|
||||||
const r = this.renderer;
|
const r = this.renderer;
|
||||||
[this.accumA, this.accumB, this.historyA, this.historyB,
|
[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));
|
.forEach((t) => r.clear(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +462,90 @@ export class Compositor {
|
|||||||
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
|
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
|
||||||
: this.layers;
|
: 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;
|
if (layer.opacity <= 0.001) continue;
|
||||||
|
|
||||||
r.clear(this.layerTarget);
|
r.clear(this.layerTarget);
|
||||||
@@ -391,7 +645,7 @@ export class Compositor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disposeTargets() {
|
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.bloomA, this.bloomB, this.outputTarget,
|
||||||
this.sharedDepthTarget].forEach((t) => t && t.dispose());
|
this.sharedDepthTarget].forEach((t) => t && t.dispose());
|
||||||
this.sharedDepthTarget = null;
|
this.sharedDepthTarget = null;
|
||||||
|
|||||||
+103
-10
@@ -4,7 +4,13 @@ import {
|
|||||||
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
|
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
|
||||||
} from './shader-contract.js';
|
} from './shader-contract.js';
|
||||||
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.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'];
|
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
|
||||||
|
|
||||||
@@ -139,6 +145,7 @@ export class Layer {
|
|||||||
this.blend = blend;
|
this.blend = blend;
|
||||||
this.palette = [];
|
this.palette = [];
|
||||||
this.personality = null;
|
this.personality = null;
|
||||||
|
this.venueState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,29 +192,53 @@ export class Layer {
|
|||||||
|
|
||||||
if (!features) return out;
|
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)) {
|
for (const [name, r] of Object.entries(reactive)) {
|
||||||
const def = defs[name];
|
const def = defs[name];
|
||||||
if (!def || def.type === 'palette') continue;
|
if (!def || def.type === 'palette') continue;
|
||||||
// Rate params multiply absolute time; modulating them jumps the phase
|
// Rate params multiply absolute time; modulating them jumps the phase
|
||||||
// by elapsed * delta. See params/schema.js RATE_FLAG.
|
// by elapsed * delta. See params/schema.js RATE_FLAG.
|
||||||
if (def.rate) continue;
|
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];
|
const raw = features[r.feature];
|
||||||
if (raw === undefined) continue;
|
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 [lo, hi] = def.range || [0, 1];
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const base = out[name] !== undefined ? out[name] : lo;
|
const base = out[name] !== undefined ? out[name] : lo;
|
||||||
|
|
||||||
if (def.type === 'vec2') {
|
if (def.type === 'vec2') {
|
||||||
out[name] = [
|
out[name] = [
|
||||||
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
|
clampValue(def, [base[0] + shaped * amount * span, 0])[0],
|
||||||
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
|
clampValue(def, [0, base[1] + shaped * amount * span])[1],
|
||||||
];
|
];
|
||||||
} else if (def.type === 'bool') {
|
} else if (def.type === 'bool') {
|
||||||
out[name] = base;
|
out[name] = base;
|
||||||
} else {
|
} else {
|
||||||
out[name] = clampValue(def, base + shaped * r.amount * span);
|
out[name] = clampValue(def, base + shaped * amount * span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -347,6 +378,7 @@ export class ModelLayer extends Layer {
|
|||||||
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
|
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
|
||||||
this.camera.position.set(0, 0, 5);
|
this.camera.position.set(0, 0, 5);
|
||||||
this.actorSpec = options.actorSpec || null;
|
this.actorSpec = options.actorSpec || null;
|
||||||
|
this.venueSpec = options.venueSpec || null;
|
||||||
// Shared rig injected by Compositor at render time when available.
|
// Shared rig injected by Compositor at render time when available.
|
||||||
this.sharedCamera = null;
|
this.sharedCamera = null;
|
||||||
this.instance = this.module.build({
|
this.instance = this.module.build({
|
||||||
@@ -355,10 +387,48 @@ export class ModelLayer extends Layer {
|
|||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
params: this.baseParams,
|
params: this.baseParams,
|
||||||
actorSpec: this.actorSpec,
|
actorSpec: this.actorSpec,
|
||||||
|
venueSpec: this.venueSpec,
|
||||||
THREE,
|
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. */
|
/** Allow the look to swap the actor without rebuilding the layer. */
|
||||||
setActor(actorSpec) {
|
setActor(actorSpec) {
|
||||||
this.actorSpec = actorSpec || null;
|
this.actorSpec = actorSpec || null;
|
||||||
@@ -369,11 +439,14 @@ export class ModelLayer extends Layer {
|
|||||||
const { timeline, features } = ctx;
|
const { timeline, features } = ctx;
|
||||||
const w = target ? target.width : renderer.width;
|
const w = target ? target.width : renderer.width;
|
||||||
const h = target ? target.height : renderer.height;
|
const h = target ? target.height : renderer.height;
|
||||||
// Use the compositor's shared camera when it has been injected; it is
|
// Who drives the camera: a module with `directCamera: true` owns its
|
||||||
// updated centrally from ArcDriver's framing/gaze so every model layer
|
// own rig outright — it drives `camera` inside update() and the shared
|
||||||
// shares one perspective and one depth, and a ground in one layer can
|
// rig is ignored for this layer. Everything else borrows the shared
|
||||||
// occlude a subject in another.
|
// camera when injected (updated centrally from framing/gaze so model
|
||||||
const cam = this.sharedCamera || this.camera;
|
// 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) {
|
if (cam.aspect !== w / h) {
|
||||||
cam.aspect = w / h;
|
cam.aspect = w / h;
|
||||||
cam.updateProjectionMatrix();
|
cam.updateProjectionMatrix();
|
||||||
@@ -391,8 +464,28 @@ export class ModelLayer extends Layer {
|
|||||||
framing: this.framing,
|
framing: this.framing,
|
||||||
opacity: this.opacity,
|
opacity: this.opacity,
|
||||||
actorSpec: this.actorSpec,
|
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,
|
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);
|
renderer.renderScene(this.scene, cam, target, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,37 @@ import { VERTEX_SHADER } from './shader-contract.js';
|
|||||||
*/
|
*/
|
||||||
export class Renderer {
|
export class Renderer {
|
||||||
constructor({ width = 1280, height = 720, canvas = null } = {}) {
|
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,
|
canvas: canvas || undefined,
|
||||||
antialias: false, // we render through targets; MSAA here buys nothing
|
antialias: false, // we render through targets; MSAA here buys nothing
|
||||||
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
|
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
|
||||||
powerPreference: 'high-performance',
|
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.autoClear = false;
|
||||||
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
|
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
|
||||||
this.gl.setSize(width, height, false);
|
this.gl.setSize(width, height, false);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }.` },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createLayer } from '../engine/Layer.js';
|
import { createLayer } from '../engine/Layer.js';
|
||||||
import { Rng } from '../engine/rng.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 { paletteShiftAt } from './paletteArc.js';
|
||||||
import { shiftPalette, lerpPalettes } from './palette.js';
|
import { shiftPalette, lerpPalettes } from './palette.js';
|
||||||
import { frameShot, neutralFraming } from './framing.js';
|
import { frameShot, neutralFraming } from './framing.js';
|
||||||
import { planGaze, gazeAt } from './Camera.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 { subjectIndexOf, isGround } from './stack.js';
|
||||||
import { groundPersonalityFrom } from '../scenes/surface.js';
|
import { groundPersonalityFrom } from '../scenes/surface.js';
|
||||||
import { derivePalettePlan, directorByName } from './directors.js';
|
import { derivePalettePlan, directorByName } from './directors.js';
|
||||||
@@ -49,6 +50,7 @@ export class ArcDriver {
|
|||||||
? look.framing : null;
|
? look.framing : null;
|
||||||
this._planFraming();
|
this._planFraming();
|
||||||
this._planGaze();
|
this._planGaze();
|
||||||
|
this._planVenueShots();
|
||||||
this._planPalette();
|
this._planPalette();
|
||||||
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
|
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,48 @@ export class ArcDriver {
|
|||||||
_planGaze() {
|
_planGaze() {
|
||||||
if (!this.look.camera) return;
|
if (!this.look.camera) return;
|
||||||
const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0);
|
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;
|
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. */
|
/** Resolve the ActorSpec for a layer, if the module requests one. */
|
||||||
_actorFor(module) {
|
_actorFor(module) {
|
||||||
const actors = this.look.actors;
|
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;
|
return actors[module.actor] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One Layer per (section, variant, layer slot), built lazily and kept. */
|
/** One Layer per (section, variant, layer slot), built lazily and kept. */
|
||||||
_layerFor(sectionIndex, variant, slot = 0) {
|
_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}`;
|
const key = `${sectionIndex}:${variant}:${slot}`;
|
||||||
let layer = this.layerCache.get(key);
|
let layer = this.layerCache.get(key);
|
||||||
if (!layer) {
|
if (!layer) {
|
||||||
const spec = this._specFor(sectionIndex, variant, slot);
|
|
||||||
const actorSpec = this._actorFor(spec.module);
|
const actorSpec = this._actorFor(spec.module);
|
||||||
|
const venueSpec = spec.module && spec.module.venue ? this._venueFor() : null;
|
||||||
layer = createLayer(spec.module, {
|
layer = createLayer(spec.module, {
|
||||||
params: spec.params,
|
params: spec.params,
|
||||||
seed: spec.seed,
|
seed: spec.seed,
|
||||||
opacity: spec.opacity,
|
opacity: spec.opacity,
|
||||||
blend: spec.blend,
|
blend: spec.blend,
|
||||||
actorSpec,
|
actorSpec,
|
||||||
|
venueSpec,
|
||||||
});
|
});
|
||||||
|
// For venue grounds, also bind venueSpec as actorSpec-like for geometry
|
||||||
layer.setPalette(this.look.palette);
|
layer.setPalette(this.look.palette);
|
||||||
layer.setPersonality(this.look.personality);
|
layer.setPersonality(this.look.personality);
|
||||||
// ModelLayers can have their actor swapped without being rebuilt — the
|
// ModelLayers can have their actor swapped without being rebuilt — the
|
||||||
// mesh is imposter-free so the geometry can be re-bound live.
|
// mesh is imposter-free so the geometry can be re-bound live.
|
||||||
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
|
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
|
||||||
|
if (venueSpec && layer.setVenue) layer.setVenue(venueSpec);
|
||||||
this.layerCache.set(key, layer);
|
this.layerCache.set(key, layer);
|
||||||
}
|
}
|
||||||
return 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.
|
* 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.
|
* 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 || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
|
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
|
||||||
if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG
|
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];
|
const [lo, hi] = def.range || [0, 1];
|
||||||
plan.push({
|
plan.push({
|
||||||
name,
|
name,
|
||||||
@@ -401,10 +521,28 @@ export class ArcDriver {
|
|||||||
const p = Math.max(0, Math.min(1, time / duration));
|
const p = Math.max(0, Math.min(1, time / duration));
|
||||||
journey = p * p * (3 - 2 * p);
|
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)) {
|
for (const item of this._slowAxisFor(spec.module)) {
|
||||||
const base = out[item.name];
|
const base = out[item.name];
|
||||||
if (typeof base !== 'number') continue;
|
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)) {
|
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
|
||||||
@@ -429,6 +567,7 @@ export class ArcDriver {
|
|||||||
const target = next.params;
|
const target = next.params;
|
||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
|
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
|
||||||
|
if (isDiscreteParam(def)) continue;
|
||||||
if (typeof target[name] !== 'number') continue;
|
if (typeof target[name] !== 'number') continue;
|
||||||
out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope);
|
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.
|
// of their range so the build still reads as a build.
|
||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (typeof out[name] !== 'number') continue;
|
if (typeof out[name] !== 'number') continue;
|
||||||
|
if (isDiscreteParam(def)) continue;
|
||||||
if (def.bias !== 'energy' && def.bias !== 'density') continue;
|
if (def.bias !== 'energy' && def.bias !== 'density') continue;
|
||||||
const hi = (def.range || [0, 1])[1];
|
const hi = (def.range || [0, 1])[1];
|
||||||
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
|
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
|
||||||
@@ -447,6 +587,92 @@ export class ArcDriver {
|
|||||||
return out;
|
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
|
* The buildSlope value on the frame before a boundary. Read from the table
|
||||||
* rather than remembered, so a seek and playback agree.
|
* 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
|
// 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.
|
// 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 base = palettes[baseIndex] || palettes[0];
|
||||||
let blendT = 0;
|
let blendT = 0;
|
||||||
let blendFrom = -1;
|
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];
|
const prevIndex = plan.cues[cueIndex - 1];
|
||||||
if (prevIndex !== baseIndex) {
|
if (prevIndex !== baseIndex) {
|
||||||
const into = frame - cue.startFrame;
|
const into = frame - cue.startFrame;
|
||||||
@@ -650,6 +878,28 @@ export class ArcDriver {
|
|||||||
|
|
||||||
const layers = [];
|
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) {
|
if (fading) {
|
||||||
// buildSlope is discontinuous at a section boundary by construction:
|
// buildSlope is discontinuous at a section boundary by construction:
|
||||||
// it ramps to ~1 through the bars before the change and is 0
|
// 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++) {
|
for (let slot = 0; slot < this._stackSize(previous); slot++) {
|
||||||
const spec = this._specFor(previous.sectionIndex, previous.variant, 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);
|
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
|
||||||
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
|
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
|
||||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
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++) {
|
for (let slot = 0; slot < this._stackSize(cue); slot++) {
|
||||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||||
const layer = this._layerFor(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.setParams(this._paramsAt(cue, slot, time, features, story));
|
||||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||||
@@ -707,12 +969,95 @@ export class ArcDriver {
|
|||||||
tension: story.tension,
|
tension: story.tension,
|
||||||
reveal: story.reveal,
|
reveal: story.reveal,
|
||||||
journey: story.journey,
|
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;
|
this.activeLayers = layers;
|
||||||
return 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. */
|
/** Which slot of a cue's stack is the shot. See look/stack.js. */
|
||||||
_subjectSlot(cue) {
|
_subjectSlot(cue) {
|
||||||
return subjectIndexOf(this._stackFor(cue));
|
return subjectIndexOf(this._stackFor(cue));
|
||||||
@@ -736,6 +1081,9 @@ export class ArcDriver {
|
|||||||
/** Invalidate caches for one section after an edit or reroll. */
|
/** Invalidate caches for one section after an edit or reroll. */
|
||||||
invalidateSection(sectionIndex) {
|
invalidateSection(sectionIndex) {
|
||||||
for (const key of [...this.layerCache.keys()]) {
|
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}:`)) {
|
if (key.startsWith(`${sectionIndex}:`)) {
|
||||||
this.layerCache.get(key).dispose();
|
this.layerCache.get(key).dispose();
|
||||||
this.layerCache.delete(key);
|
this.layerCache.delete(key);
|
||||||
@@ -746,6 +1094,7 @@ export class ArcDriver {
|
|||||||
this.cues = this._buildCues();
|
this.cues = this._buildCues();
|
||||||
this._planFraming();
|
this._planFraming();
|
||||||
this._planGaze();
|
this._planGaze();
|
||||||
|
this._planVenueShots();
|
||||||
this._planPalette();
|
this._planPalette();
|
||||||
this._paletteKey = null;
|
this._paletteKey = null;
|
||||||
this._slopeCache = null;
|
this._slopeCache = null;
|
||||||
@@ -757,6 +1106,7 @@ export class ArcDriver {
|
|||||||
this.cues = this._buildCues();
|
this.cues = this._buildCues();
|
||||||
this._planFraming();
|
this._planFraming();
|
||||||
this._planGaze();
|
this._planGaze();
|
||||||
|
this._planVenueShots();
|
||||||
this._planPalette();
|
this._planPalette();
|
||||||
this._paletteKey = null;
|
this._paletteKey = null;
|
||||||
this._slopeCache = null;
|
this._slopeCache = null;
|
||||||
@@ -777,6 +1127,12 @@ export class ArcDriver {
|
|||||||
if (onLayer) onLayer(layer);
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,6 +1177,11 @@ export class ArcDriver {
|
|||||||
return this._framingAt(this._cueIndexAt(frame), frame);
|
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) {
|
personalityForFrame(frame, story) {
|
||||||
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
|
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
|
||||||
return this._personalityAt(s);
|
return this._personalityAt(s);
|
||||||
|
|||||||
@@ -279,9 +279,11 @@ function timingFor(spanFrames, story, energy, camera, curve, rng) {
|
|||||||
*/
|
*/
|
||||||
function relocatesAt(cue, story, rng) {
|
function relocatesAt(cue, story, rng) {
|
||||||
if (!cue.hardCut && cue.atSectionStart) return true; // a new section is a new place
|
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;
|
const tension = story ? story.tension : 0.5;
|
||||||
let chance = 0.12 + tension * 0.45;
|
let chance = 0.12 + tension * 0.45;
|
||||||
if (cue.hardCut) chance += 0.3;
|
|
||||||
if (story) {
|
if (story) {
|
||||||
if (story.act === 'climax') chance += 0.2;
|
if (story.act === 'climax') chance += 0.2;
|
||||||
else if (story.act === 'resolution') chance *= 0.4;
|
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 {Array} sections look.sections, for bias and story state
|
||||||
* @param {object} camera from deriveCamera
|
* @param {object} camera from deriveCamera
|
||||||
* @param {Rng} rng
|
* @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
|
* @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 = [];
|
const moves = [];
|
||||||
let at = [0, 0];
|
let at = [0, 0];
|
||||||
let previousDir = null;
|
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
|
// snap this causes lands exactly on a cut, which is where the eye is
|
||||||
// least able to see it.
|
// least able to see it.
|
||||||
let from = clampToReach(at, reach);
|
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 hop = jumpFor(story, !!cue.hardCut, rng) * reach;
|
||||||
const jumped = targetFor(from, hop, camera, story, rng, previousDir);
|
const jumped = targetFor(from, hop, camera, story, rng, previousDir);
|
||||||
from = clampToReach(jumped.point, reach);
|
from = clampToReach(jumped.point, reach);
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
|||||||
import {
|
import {
|
||||||
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
|
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
|
||||||
} from './directors.js';
|
} 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 { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
||||||
import { deriveFramingStyle, describeFraming } from './framing.js';
|
import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||||
import { deriveCamera, describeCamera } from './Camera.js';
|
import { deriveCamera, describeCamera } from './Camera.js';
|
||||||
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
|
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
|
||||||
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.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
|
// 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
|
// (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;
|
return picked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _activeVenueFamily = null; // set per-track for venue-family lean
|
||||||
function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||||
const families = director.families[kind] || Object.keys(FAMILIES);
|
const families = director.families[kind] || Object.keys(FAMILIES);
|
||||||
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
|
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 = [];
|
const candidates = [];
|
||||||
for (const family of families) {
|
for (const family of families) {
|
||||||
const inFamily = scenesInFamily(family)
|
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
|
// Weight by family preference order, and push down anything already
|
||||||
// used so a five-section track doesn't show one scene five times.
|
// used so a five-section track doesn't show one scene five times.
|
||||||
const weight = families.length - families.indexOf(family);
|
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
|
// The signature is a lean now, not a wall. See
|
||||||
// Personality.signatureWeight for why it had to stop being one.
|
// Personality.signatureWeight for why it had to stop being one.
|
||||||
weight: weight * signatureWeight(scene, signature)
|
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) {
|
if (!candidates.length) {
|
||||||
// This track's pool holds nothing in the families the director wants for
|
// Empty after filter — widen once via filterScenesSync's 1.15× ceiling,
|
||||||
// this kind. Widen to the pool, then to the library — the track keeps
|
// else fall back to pool→library so a section is never empty; validator
|
||||||
// its scenes either way.
|
// 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);
|
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;
|
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.
|
// and it is worse underneath than on top because nothing hides it.
|
||||||
if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
|
if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
|
||||||
w *= signatureWeight(m, signature);
|
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
|
// Overshooting the budget is allowed and discouraged: the ground is
|
||||||
// mandatory, so an oversized one is spent frame the shot cannot use.
|
// mandatory, so an oversized one is spent frame the shot cannot use.
|
||||||
w /= 1 + 4 * Math.max(0, sceneCoverage(m) - headroom);
|
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,
|
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||||
{ ground = null, director = null, kind = null } = {}) {
|
{ ground = null, director = null, kind = null } = {}) {
|
||||||
const sectionKind = kind;
|
const sectionKind = kind;
|
||||||
// A shot that fills the frame by itself is its own ground; anything else
|
// Venue is always the ground — even a canvas shot stands on its venue.
|
||||||
// gets one under it.
|
// (canGround(venue) is false by design, so the old rule would think a
|
||||||
const standsAlone = canGround(module) || !ground;
|
// canvas viz needs no ground and drop the venue.)
|
||||||
|
const isVenueGround = !!(ground && ground.venue);
|
||||||
|
const standsAlone = !isVenueGround && (canGround(module) || !ground);
|
||||||
|
|
||||||
// --- the blaze ------------------------------------------------------
|
// --- the blaze ------------------------------------------------------
|
||||||
// Whether THIS section is one the director lets bloom out: the shot added
|
// 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
|
// 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
|
// story. Quiet kinds never blaze; a breakdown that goes white is not a
|
||||||
// decision, it is a bug with a rationale.
|
// 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
|
const blaze = !standsAlone
|
||||||
&& !QUIET_KINDS.includes(sectionKind)
|
&& !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));
|
* (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
|
// 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
|
// same shot it always did and the ground arrives underneath it rather than
|
||||||
// in front of it in the seed stream.
|
// 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 = {
|
const shot = {
|
||||||
module,
|
module,
|
||||||
params: sampleValues(module, rng, bias, temperament),
|
params: shotParams,
|
||||||
seed: rng.int(0, 0x7fffffff),
|
seed: rng.int(0, 0x7fffffff),
|
||||||
// Keyed over the ground on its own brightness by default: what the shot
|
// 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
|
// 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.
|
// with something happening in it. See scenes/surface.js.
|
||||||
const offFamily = available.filter((m) => m.family !== module.family);
|
const offFamily = available.filter((m) => m.family !== module.family);
|
||||||
const pool = offFamily.length ? offFamily : available;
|
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);
|
spent += sceneCoverage(overlay);
|
||||||
available = available.filter((m) => m.name !== overlay.name
|
available = available.filter((m) => m.name !== overlay.name
|
||||||
&& m.family !== overlay.family);
|
&& m.family !== overlay.family);
|
||||||
@@ -767,6 +829,7 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
|||||||
export function generateLook(track, {
|
export function generateLook(track, {
|
||||||
seed = null, samples = null, overrides = null,
|
seed = null, samples = null, overrides = null,
|
||||||
pool: poolOverride = null, poolSize = POOL_SIZE,
|
pool: poolOverride = null, poolSize = POOL_SIZE,
|
||||||
|
venueEnabled = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const resolvedSeed = seed !== null
|
const resolvedSeed = seed !== null
|
||||||
? seed >>> 0
|
? seed >>> 0
|
||||||
@@ -805,8 +868,38 @@ export function generateLook(track, {
|
|||||||
// section, and whether the outro answers the intro. See look/Story.js.
|
// section, and whether the outro answers the intro. See look/Story.js.
|
||||||
const story = deriveStory(track, summary, rng.fork('story'));
|
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(
|
const rosterByKind = assignRostersByKind(
|
||||||
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
||||||
|
_activeVenueFamily = null;
|
||||||
if (story.recap) applyRecap(rosterByKind);
|
if (story.recap) applyRecap(rosterByKind);
|
||||||
// What each kind's sections stand on. After the recap, so the outro is
|
// What each kind's sections stand on. After the recap, so the outro is
|
||||||
// grounded against the roster it actually ends up with.
|
// 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.
|
// so the last occurrence of a kind samples further out than the first.
|
||||||
const temperament = temperamentFor(personality.temperament, state);
|
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(
|
const variants = roster.map((module, v) => buildStack(
|
||||||
module, overlayRoster, bias,
|
module, overlayRoster, bias,
|
||||||
@@ -887,6 +987,8 @@ export function generateLook(track, {
|
|||||||
|
|
||||||
const look = {
|
const look = {
|
||||||
seed: resolvedSeed,
|
seed: resolvedSeed,
|
||||||
|
venue,
|
||||||
|
venueEnabled,
|
||||||
palette,
|
palette,
|
||||||
// The full set the director cuts between — ArcDriver reads this per cue.
|
// The full set the director cuts between — ArcDriver reads this per cue.
|
||||||
palettes,
|
palettes,
|
||||||
@@ -906,6 +1008,112 @@ export function generateLook(track, {
|
|||||||
summary,
|
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;
|
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
|
// 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.
|
// 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 size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
|
||||||
const roster = [];
|
const roster = [];
|
||||||
while (roster.length < size) {
|
while (roster.length < size) {
|
||||||
const options = candidates.filter((m) => !roster.includes(m));
|
const options = candidates.filter((m) => !roster.includes(m));
|
||||||
if (!options.length) break;
|
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]);
|
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;
|
const state = section.story || NEUTRAL_STATE;
|
||||||
// The section is re-cast, so its ground is re-cast with it — a reroll that
|
// 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.
|
// kept the old bed under new shots would be answering half the question.
|
||||||
const ground = castGround(
|
// When venue is enabled the venue IS the ground and must not be re-cast per section.
|
||||||
section.kind, roster, rng.fork('ground'), signature, director, new Set());
|
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(
|
section.variants = roster.map((module, v) => buildStack(
|
||||||
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||||
temperamentFor(look.personality && look.personality.temperament, state), state,
|
temperamentFor(look.personality && look.personality.temperament, state), state,
|
||||||
@@ -961,7 +1175,7 @@ export function rerollLook(look, track, newSeed) {
|
|||||||
const locked = new Map();
|
const locked = new Map();
|
||||||
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
|
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) => {
|
next.sections.forEach((s, i) => {
|
||||||
if (locked.has(i)) next.sections[i] = locked.get(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;
|
look.palette = look.palettes[0] || look.palette;
|
||||||
}
|
}
|
||||||
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
|
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.post) look.post = { ...look.post, ...overrides.post };
|
||||||
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
|
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
|
||||||
if (overrides.sections) {
|
if (overrides.sections) {
|
||||||
@@ -1005,10 +1220,11 @@ export function describeLook(look) {
|
|||||||
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
|
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
|
||||||
: '';
|
: '';
|
||||||
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
|
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
|
||||||
|
const venueTag = look.venue ? ` · ${describeVenue(look.venue)}` : '';
|
||||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
|
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
|
||||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||||
`${describeCamera(look.camera)}${planTag}${actorTag} · ` +
|
`${describeCamera(look.camera)}${planTag}${actorTag}${venueTag} · ` +
|
||||||
`${[...new Set(kinds)].join(', ')}`;
|
`${[...new Set(kinds)].join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -64,6 +64,18 @@ const DEFAULT_CROWD = 1.2;
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_BLAZE = 0.15;
|
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
|
* 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
|
* 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 = [
|
export const DIRECTORS = [
|
||||||
{
|
{
|
||||||
name: 'ambient',
|
name: 'ambient',
|
||||||
|
comfort: 0.60,
|
||||||
// Space, not glare. A blaze here is the exception that proves it.
|
// Space, not glare. A blaze here is the exception that proves it.
|
||||||
blaze: 0.1,
|
blaze: 0.1,
|
||||||
// Space is the subject, so the frame stays owed to it: one
|
// Space is the subject, so the frame stays owed to it: one
|
||||||
@@ -97,6 +110,7 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'brutalist',
|
name: 'brutalist',
|
||||||
|
comfort: 0.60,
|
||||||
// Mass does not glow. Almost never.
|
// Mass does not glow. Almost never.
|
||||||
blaze: 0.05,
|
blaze: 0.05,
|
||||||
// One large thing, seen whole. Layering hides mass, which is the
|
// One large thing, seen whole. Layering hides mass, which is the
|
||||||
@@ -120,6 +134,7 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organicist',
|
name: 'organicist',
|
||||||
|
comfort: 0.65,
|
||||||
// Light through leaves — bloom belongs to this world.
|
// Light through leaves — bloom belongs to this world.
|
||||||
blaze: 0.2,
|
blaze: 0.2,
|
||||||
// Growth accumulates. Things overlap here because that is what
|
// Growth accumulates. Things overlap here because that is what
|
||||||
@@ -143,6 +158,7 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'corrupt',
|
name: 'corrupt',
|
||||||
|
comfort: 0.85,
|
||||||
// Overload is the subject. Half its drops go white.
|
// Overload is the subject. Half its drops go white.
|
||||||
blaze: 0.5,
|
blaze: 0.5,
|
||||||
// Everything over everything. The damage is the subject and it is
|
// Everything over everything. The damage is the subject and it is
|
||||||
@@ -167,6 +183,7 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'geometer',
|
name: 'geometer',
|
||||||
|
comfort: 0.70,
|
||||||
// Exact, and occasionally exact and incandescent.
|
// Exact, and occasionally exact and incandescent.
|
||||||
blaze: 0.15,
|
blaze: 0.15,
|
||||||
// Pattern on pattern is a moiré, which is a pattern. Layers are
|
// 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];
|
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 ───────────────────────────────────────────────
|
// ── palette progression ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -9,6 +9,14 @@ import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
|
|||||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
|
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
|
||||||
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
|
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
|
||||||
import { coverageOf as sceneCoverage } from './scenes/surface.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 = {
|
const QUALITY = {
|
||||||
draft: 0.5, // half resolution — for scrubbing heavy stacks
|
draft: 0.5, // half resolution — for scrubbing heavy stacks
|
||||||
@@ -44,8 +52,37 @@ const dom = {
|
|||||||
exportWarnDontAsk: document.getElementById('ew-dontask'),
|
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 = {
|
const state = {
|
||||||
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
|
show: bootShow(),
|
||||||
playing: false,
|
playing: false,
|
||||||
quality: 'full',
|
quality: 'full',
|
||||||
loopSection: -1,
|
loopSection: -1,
|
||||||
@@ -63,11 +100,24 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- loading
|
// ---------------------------------------------------------------- loading
|
||||||
|
|
||||||
async function loadFile(file, { seed = null } = {}) {
|
async function loadFile(file, { seed = null, pins = null, frame = null, warm = null } = {}) {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
stopPlayback();
|
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) {
|
if (dom.overlay) {
|
||||||
dom.overlay.hidden = true;
|
dom.overlay.hidden = true;
|
||||||
dom.overlay.style.display = 'none';
|
dom.overlay.style.display = 'none';
|
||||||
@@ -83,7 +133,7 @@ async function loadFile(file, { seed = null } = {}) {
|
|||||||
await state.show.load(file, (stage, fraction) => {
|
await state.show.load(file, (stage, fraction) => {
|
||||||
dom.thStep.textContent = stage;
|
dom.thStep.textContent = stage;
|
||||||
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
||||||
}, { seed });
|
}, { seed, pins });
|
||||||
|
|
||||||
dom.audio.src = URL.createObjectURL(file);
|
dom.audio.src = URL.createObjectURL(file);
|
||||||
dom.thLabel.textContent = 'loaded track';
|
dom.thLabel.textContent = 'loaded track';
|
||||||
@@ -96,13 +146,31 @@ async function loadFile(file, { seed = null } = {}) {
|
|||||||
strip.setShow(state.show);
|
strip.setShow(state.show);
|
||||||
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
|
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
|
||||||
resize();
|
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();
|
renderPanel();
|
||||||
document.title = `flow-state · ${state.show.fileName}`;
|
document.title = `flow-state · ${state.show.fileName}`;
|
||||||
console.info('[flow-state]', describeLook(state.show.look));
|
console.info('[flow-state]', describeLook(state.show.look));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dom.thLabel.textContent = 'analysis failed';
|
const refused = err && err.code === 'COMPOSITION_REFUSED';
|
||||||
dom.thStep.textContent = `failed: ${err.message}`;
|
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);
|
console.error(err);
|
||||||
dom.changeTrack.hidden = false;
|
dom.changeTrack.hidden = false;
|
||||||
dom.changeTrack.textContent = 'try again';
|
dom.changeTrack.textContent = 'try again';
|
||||||
@@ -120,6 +188,27 @@ if (dom.changeTrack) dom.changeTrack.addEventListener('click', () => dom.fileInp
|
|||||||
dom.fileInput.addEventListener('change', (e) => {
|
dom.fileInput.addEventListener('change', (e) => {
|
||||||
if (e.target.files[0]) loadFile(e.target.files[0]);
|
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.
|
* `?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
|
* 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
|
* 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.
|
* 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
|
* 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
|
* 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);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
const blob = await res.blob();
|
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) {
|
} catch (err) {
|
||||||
dom.thLabel.textContent = 'no such song';
|
dom.thLabel.textContent = 'no such song';
|
||||||
dom.thName.hidden = false;
|
dom.thName.hidden = false;
|
||||||
@@ -249,9 +354,26 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- look edits
|
// ---------------------------------------------------------------- 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', () => {
|
document.getElementById('btn-reroll').addEventListener('click', () => {
|
||||||
if (!state.show.ready) return;
|
if (!state.show.ready) return;
|
||||||
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
|
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
|
||||||
|
persistSeedInUrl();
|
||||||
refreshAfterLookChange();
|
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>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>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>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>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>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
|
||||||
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
|
<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
|
<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>`;
|
the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
|
||||||
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,3 +955,349 @@ if (query.get('song')) {
|
|||||||
const seed = query.get('seed');
|
const seed = query.get('seed');
|
||||||
loadBankSong(query.get('song'), seed === null ? null : (Number(seed) >>> 0));
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ export const RATE_FLAG = 'rate';
|
|||||||
// all.
|
// all.
|
||||||
export const SLOW_AXIS_FLAG = 'slowAxis';
|
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. */
|
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
|
||||||
export const REACTIVE_FEATURES = [
|
export const REACTIVE_FEATURES = [
|
||||||
'loudness', 'rms',
|
'loudness', 'rms',
|
||||||
@@ -321,6 +343,12 @@ export function validateModule(module) {
|
|||||||
if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
|
if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
|
||||||
errors.push(`${id}: \`readsDepth\` must be 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 || {};
|
const params = module.params || {};
|
||||||
@@ -358,6 +386,10 @@ export function validateModule(module) {
|
|||||||
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
|
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
|
||||||
errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
|
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 — ` +
|
errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` +
|
||||||
`modulating it jumps phase by elapsed*delta (see RATE_FLAG)`);
|
`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;
|
return errors;
|
||||||
|
|||||||
@@ -1475,7 +1475,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Moiré Grid": {
|
"Moir\u00e9 Grid": {
|
||||||
"coverage": 0.327,
|
"coverage": 0.327,
|
||||||
"bedCoverage": 0.02,
|
"bedCoverage": 0.02,
|
||||||
"bedCoverageMean": 0.369,
|
"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": {
|
"Prism Bloom": {
|
||||||
"coverage": 0.085,
|
"coverage": 0.085,
|
||||||
"bedCoverage": 0.013,
|
"bedCoverage": 0.013,
|
||||||
@@ -5257,7 +5135,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Kármán Street": {
|
"K\u00e1rm\u00e1n Street": {
|
||||||
"coverage": 0.258,
|
"coverage": 0.258,
|
||||||
"bedCoverage": 0.047,
|
"bedCoverage": 0.047,
|
||||||
"bedCoverageMean": 0.269,
|
"bedCoverageMean": 0.269,
|
||||||
@@ -8550,7 +8428,7 @@
|
|||||||
0
|
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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ import { neonCity } from './shader/neon-city.js';
|
|||||||
import { flora } from './shader/flora.js';
|
import { flora } from './shader/flora.js';
|
||||||
import { fireflyDrift } from './shader/firefly-drift.js';
|
import { fireflyDrift } from './shader/firefly-drift.js';
|
||||||
import { silkRibbon } from './shader/silk-ribbon.js';
|
import { silkRibbon } from './shader/silk-ribbon.js';
|
||||||
import { pylonGrid } from './shader/pylon-grid.js';
|
|
||||||
import { prismBloom } from './shader/prism-bloom.js';
|
import { prismBloom } from './shader/prism-bloom.js';
|
||||||
import { pitchShatter } from './shader/pitch-shatter.js';
|
import { pitchShatter } from './shader/pitch-shatter.js';
|
||||||
import { auroraVeil } from './shader/aurora-veil.js';
|
import { auroraVeil } from './shader/aurora-veil.js';
|
||||||
@@ -114,7 +114,6 @@ const MODULES = [
|
|||||||
flora,
|
flora,
|
||||||
fireflyDrift,
|
fireflyDrift,
|
||||||
silkRibbon,
|
silkRibbon,
|
||||||
pylonGrid,
|
|
||||||
prismBloom,
|
prismBloom,
|
||||||
pitchShatter,
|
pitchShatter,
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const analogWow = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
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' },
|
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
|
// 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.
|
// 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 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)));
|
// Flux window — dropouts cluster rather than striking everywhere at once.
|
||||||
float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit;
|
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)));
|
vec3 noise = pal(1) * (0.25 + 0.5 * hash12(vec2(uv.x * 400.0, bandId)));
|
||||||
col = mix(col, noise, band * 0.7);
|
col = mix(col, noise, band * 0.7);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const blockMosh = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
bleed: { feature: 'flux', amount: 0.2, response: 'spike' },
|
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: `
|
shader: `
|
||||||
@@ -66,11 +66,12 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
float m = clamp(u_bleed * (0.5 + reach), 0.0, 1.0);
|
float m = clamp(u_bleed * (0.5 + reach), 0.0, 1.0);
|
||||||
vec3 col = mix(base, mosh, m);
|
vec3 col = mix(base, mosh, m);
|
||||||
|
|
||||||
// Weak block tint, pulse-pinned to the beat but kept small — the frame as a
|
// Weak block tint, pulse-pinned to the beat but kept small — and now
|
||||||
// whole never swings in luminance, so it stays clear of the WCAG 2.3.1
|
// windowed, so only a subset of blocks brighten rather than the whole frame.
|
||||||
// flash ceiling even on a hard four-on-the-floor.
|
|
||||||
float blk = hash12(cell + st * 7.0);
|
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);
|
return vec4(inkValue(col), 1.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const fireflyDrift = {
|
|||||||
traits: ['camera', 'style'],
|
traits: ['camera', 'style'],
|
||||||
|
|
||||||
params: {
|
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 },
|
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' },
|
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' },
|
spread: { type: 'float', range: [0.4, 1.3], default: 0.95, uniform: 'u_spread' },
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export const flora = {
|
|||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|
||||||
params: {
|
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' },
|
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' },
|
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' },
|
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.
|
// Soft vertical wash behind the plants.
|
||||||
vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5));
|
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;
|
if (i >= int(u_count)) break;
|
||||||
float fi = float(i);
|
float fi = float(i);
|
||||||
float s = u_seed + fi * 43.7;
|
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.
|
// Stem, sampled so the bend follows the sway curve rather than a chord.
|
||||||
float stemMask = 0.0;
|
float stemMask = 0.0;
|
||||||
for (int j = 0; j < 12; j++) {
|
for (int j = 0; j < 8; j++) {
|
||||||
float hj = (float(j) + 0.5) / 12.0;
|
float hj = (float(j) + 0.5) / 8.0;
|
||||||
float cx = baseX + swayAt(hj, s, t) * u_sway;
|
float cx = baseX + swayAt(hj, s, t) * u_sway;
|
||||||
float cy = baseY + hj * height;
|
float cy = baseY + hj * height;
|
||||||
float d = length(p - vec2(cx, cy));
|
float d = length(p - vec2(cx, cy));
|
||||||
@@ -87,9 +91,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
float tipY = baseY + height;
|
float tipY = baseY + height;
|
||||||
|
|
||||||
// Petals up the upper half, alternating sides.
|
// 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 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 side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0;
|
||||||
float nx = baseX + swayAt(node, s, t) * u_sway;
|
float nx = baseX + swayAt(node, s, t) * u_sway;
|
||||||
float ny = baseY + node * height;
|
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.
|
// Bloom: a small corona of signature forms around the crown.
|
||||||
float bloom = 0.0;
|
float bloom = 0.0;
|
||||||
for (int k = 0; k < 6; k++) {
|
for (int k = 0; k < 4; k++) {
|
||||||
float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s;
|
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);
|
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);
|
bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const gateCorridor = {
|
|||||||
traits: ['shape', 'camera', 'space', 'style'],
|
traits: ['shape', 'camera', 'space', 'style'],
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' , slowAxis: true },
|
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density', slowAxis: true },
|
||||||
aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' },
|
aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' },
|
||||||
thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' },
|
thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' },
|
||||||
travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true },
|
travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true },
|
||||||
@@ -31,7 +31,7 @@ export const gateCorridor = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
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' },
|
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 * frame * (0.25 + near * 0.75);
|
||||||
col += tint * exp(-d * 14.0) * near * 0.2;
|
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.
|
// A lamp at the top of every third gate, pulsing on the beat.
|
||||||
if (mod(fi, 3.0) < 0.5) {
|
if (mod(fi, 3.0) < 0.5) {
|
||||||
vec2 lampAt = vanishAt + vec2(0.0, scale);
|
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'],
|
traits: ['space', 'camera', 'style'],
|
||||||
|
|
||||||
params: {
|
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' },
|
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' },
|
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 },
|
speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const metaballs = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
radius: { feature: 'bandLow', amount: 0.25 },
|
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' },
|
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,7 +64,11 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
|
|
||||||
vec3 col = pal(0) * 0.06;
|
vec3 col = pal(0) * 0.06;
|
||||||
col = mix(col, tint, surface);
|
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);
|
return vec4(inkValue(col), 1.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const pitchShatter = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
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' },
|
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.
|
// step, decided by a hash of (slice, step) so it jumps cleanly on the grid.
|
||||||
float slice = floor(uv.x * u_slices);
|
float slice = floor(uv.x * u_slices);
|
||||||
float sliceRand = hash12(vec2(slice, step_ * 31.0));
|
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
|
// 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.
|
// 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
|
// The smear: pull the previous frame across the pitch so slices leave a
|
||||||
// transient ghost as they land, and the corruption feels sludgy.
|
// 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));
|
col = mix(col, ghost, u_bleed * (0.4 + sliceRand));
|
||||||
|
|
||||||
// A thin scan beam rooting the hard edge of each step, kept local and beat
|
// A thin scan beam rooting the hard edge of each step, kept local and beat
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
@@ -32,7 +32,7 @@ export const scaleMosaic = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
sheen: { feature: 'bandHigh', amount: 0.35, response: 'smooth' },
|
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: `
|
shader: `
|
||||||
@@ -52,10 +52,14 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
|
|
||||||
vec2 cell = floor(q);
|
vec2 cell = floor(q);
|
||||||
vec2 f = fract(q) - 0.5;
|
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
|
// 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.
|
// 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 breath = 1.0 + sin(u_barPhase * 6.28318530718) * u_breathe;
|
||||||
float rad = u_size * breath * (1.0 + lift * 0.3);
|
float rad = u_size * breath * (1.0 + lift * 0.3);
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export const scanTear = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
shift: { feature: 'beat', amount: 0.5, response: 'spike' },
|
shift: { feature: 'beat', amount: 0.14, response: 'smooth' },
|
||||||
tear: { feature: 'flux', amount: 0.35, response: 'spike' },
|
tear: { feature: 'flux', amount: 0.22, response: 'smooth' },
|
||||||
slices:{ feature: 'bandHigh', amount: 0.25 },
|
slices:{ feature: 'bandHigh', amount: 0.25 },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -40,12 +40,20 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
|
|
||||||
float row = floor(uv.y * u_slices);
|
float row = floor(uv.y * u_slices);
|
||||||
float rowRandom = hash12(vec2(row, step_));
|
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.
|
// Only some rows tear, and only above the tear threshold.
|
||||||
// Slice weight follows the track's line weight — the same art direction
|
// Slice weight follows the track's line weight — the same art direction
|
||||||
// that thickens a grid line thickens a tear.
|
// that thickens a grid line thickens a tear.
|
||||||
float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom);
|
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
|
// 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.
|
// 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.
|
// Smear the previous frame along the displacement.
|
||||||
vec3 ghost = prev(vec2(fract(uv.x + offset * 0.6), uv.y));
|
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);
|
return vec4(inkValue(col), 1.0);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const stairwellDescent = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
|
lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
|
||||||
rails: { feature: 'bandMid', amount: 0.15, response: 'smooth' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const tideRings = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
caustic: { feature: 'beat', amount: 0.3, response: 'smooth' },
|
caustic: { feature: 'beat', amount: 0.14, response: 'smooth' },
|
||||||
wavelength: { feature: 'bandMid', amount: 0.2 },
|
wavelength: { feature: 'bandMid', amount: 0.2 },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -60,6 +60,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
float wave = sum / max(energy, 1e-3);
|
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
|
// 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.
|
// 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);
|
nodes = pow(sat(nodes), 3.0);
|
||||||
|
|
||||||
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
|
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
|
||||||
col += pal(3) * nodes * u_caustic;
|
col += pal(3) * nodes * maskedCaustic;
|
||||||
col += pal(4) * pow(sat(wave), 6.0) * u_caustic * 0.5;
|
col += pal(4) * pow(sat(wave), 6.0) * maskedCaustic * 0.5;
|
||||||
|
|
||||||
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
||||||
return vec4(inkValue(col), 1.0);
|
return vec4(inkValue(col), 1.0);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const assembly = {
|
|||||||
}
|
}
|
||||||
instance.heroPaletteMap = actorSpec.paletteMap || [];
|
instance.heroPaletteMap = actorSpec.paletteMap || [];
|
||||||
instance.heroBuilt = true;
|
instance.heroBuilt = true;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- satellites: small instances so count/spread visibly matter ---
|
// --- satellites: small instances so count/spread visibly matter ---
|
||||||
@@ -173,6 +174,7 @@ export const assembly = {
|
|||||||
}
|
}
|
||||||
instance.satBuilt = true;
|
instance.satBuilt = true;
|
||||||
instance.satCount = needCount;
|
instance.satCount = needCount;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
|
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
|
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
|
||||||
//
|
//
|
||||||
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
|
// A rows×columns grid of chorus-stacked legs (segs from stageScale) with a
|
||||||
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
|
// beat-walk pulse — as BufferGeometry with real occlusion, parallax and scale
|
||||||
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
|
// foreshortening. Crowns are the protagonist assembly via actorToGeometry,
|
||||||
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
|
// legs are chorus rungs, ground is a plane at sigHorizonY level.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
|
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
|
||||||
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
|
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
|
||||||
@@ -26,8 +23,8 @@ export const pylonField3D = {
|
|||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true },
|
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' },
|
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' },
|
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' },
|
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' },
|
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
|
||||||
@@ -37,7 +34,6 @@ export const pylonField3D = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
||||||
rows: { feature: 'bandLow', amount: 0.25 },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
build({ scene, seed, params, actorSpec, THREE }) {
|
build({ scene, seed, params, actorSpec, THREE }) {
|
||||||
@@ -87,7 +83,6 @@ export const pylonField3D = {
|
|||||||
const t = timeline.time;
|
const t = timeline.time;
|
||||||
const beat = features.beat || 0;
|
const beat = features.beat || 0;
|
||||||
const beatPhase = features.beatPhase || 0;
|
const beatPhase = features.beatPhase || 0;
|
||||||
const bandLow = features.bandLow || 0;
|
|
||||||
|
|
||||||
const identity = personality ? personality.identity : null;
|
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]];
|
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));
|
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 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 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);
|
const height = Math.max(0.2, params.height);
|
||||||
@@ -303,6 +298,7 @@ export const pylonField3D = {
|
|||||||
instance.builtRows = rows;
|
instance.builtRows = rows;
|
||||||
instance.builtIdentityKey = identityKey;
|
instance.builtIdentityKey = identityKey;
|
||||||
instance.pylonCount = cols * rows;
|
instance.pylonCount = cols * rows;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Palette + opacity live rebind so paletteArc is visible.
|
// Palette + opacity live rebind so paletteArc is visible.
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const synthwaveCorridor = {
|
|||||||
instance.heroGroup.add(m);
|
instance.heroGroup.add(m);
|
||||||
}
|
}
|
||||||
instance.heroBuilt = true;
|
instance.heroBuilt = true;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passers — rebuilt only when count changes or identity changes materially.
|
// Passers — rebuilt only when count changes or identity changes materially.
|
||||||
@@ -306,6 +307,7 @@ export const synthwaveCorridor = {
|
|||||||
instance.passerGroup.add(mesh);
|
instance.passerGroup.add(mesh);
|
||||||
}
|
}
|
||||||
instance.passerCount = passerCount;
|
instance.passerCount = passerCount;
|
||||||
|
instance.needsCompile = true;
|
||||||
} else if (passerCount === 0) {
|
} else if (passerCount === 0) {
|
||||||
for (const child of [...instance.passerGroup.children]) {
|
for (const child of [...instance.passerGroup.children]) {
|
||||||
instance.passerGroup.remove(child);
|
instance.passerGroup.remove(child);
|
||||||
|
|||||||
@@ -132,7 +132,19 @@ const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || nul
|
|||||||
*/
|
*/
|
||||||
const ASSUMED_COVERAGE = 0.15;
|
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) {
|
export function coverageOf(module) {
|
||||||
|
if (isVenue(module)) return 0.72;
|
||||||
const row = ROW(module);
|
const row = ROW(module);
|
||||||
return row ? row.coverage : ASSUMED_COVERAGE;
|
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.
|
* asks. See GROUND_BIAS for why the two numbers differ so much.
|
||||||
*/
|
*/
|
||||||
export function groundCoverageOf(module) {
|
export function groundCoverageOf(module) {
|
||||||
|
if (isVenue(module)) return 0.72;
|
||||||
const row = ROW(module);
|
const row = ROW(module);
|
||||||
if (!row) return ASSUMED_COVERAGE;
|
if (!row) return ASSUMED_COVERAGE;
|
||||||
if (row.bedCoverageMean !== undefined) return row.bedCoverageMean;
|
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.
|
* an occasional dim intro. So: fills the frame on average, and never vanishes.
|
||||||
*/
|
*/
|
||||||
export function groundFloorCoverageOf(module) {
|
export function groundFloorCoverageOf(module) {
|
||||||
|
if (isVenue(module)) return 0.62;
|
||||||
const row = ROW(module);
|
const row = ROW(module);
|
||||||
if (!row) return ASSUMED_COVERAGE;
|
if (!row) return ASSUMED_COVERAGE;
|
||||||
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
|
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
|
||||||
@@ -191,6 +205,8 @@ export function surfaceOf(module) {
|
|||||||
* tolerance of 4.
|
* tolerance of 4.
|
||||||
*/
|
*/
|
||||||
export function canGround(module) {
|
export function canGround(module) {
|
||||||
|
if (isVenue(module)) return false;
|
||||||
|
if (module && module.readsDepth) return false;
|
||||||
return isMeasured(module)
|
return isMeasured(module)
|
||||||
&& surfaceOf(module) === 'canvas'
|
&& surfaceOf(module) === 'canvas'
|
||||||
&& groundCoverageOf(module) >= GROUND_MIN
|
&& groundCoverageOf(module) >= GROUND_MIN
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
/* Single-scene gate output (checks.html?scene=Name). Monospaced so the
|
||||||
PASS/FAIL column lines up and the whole verdict reads in one glance. */
|
PASS/FAIL column lines up and the whole verdict reads in one glance. */
|
||||||
.scene-gate {
|
.scene-gate {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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,7 +22,7 @@ import { resolve, dirname } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { synthesizeSong } from '../src/audio/synth.js';
|
import { synthesizeSong } from '../src/audio/synth.js';
|
||||||
import { FeatureTrack } from '../src/audio/FeatureTrack.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));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -41,7 +41,9 @@ const CHECK_ONLY = has('check');
|
|||||||
* 16-bit PCM WAV, mono.
|
* 16-bit PCM WAV, mono.
|
||||||
*
|
*
|
||||||
* Mono halves the size and loses nothing: the analyser sums to mono before it
|
* 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) {
|
function wav(samples, sampleRate) {
|
||||||
const bytes = samples.length * 2;
|
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
|
// 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
|
// 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.
|
// asked for — the bank would then be testing the encoder, not the generator.
|
||||||
let peak = 0;
|
// Shared with songbank.js bankPcm: the files must hold exactly what the
|
||||||
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
|
// probes analyse, or app-vs-probe previews can never match.
|
||||||
const gain = peak > 1e-9 ? 0.89 / peak : 1;
|
const pcm = bankPcm(samples);
|
||||||
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
for (let i = 0; i < pcm.length; i++) {
|
||||||
const v = Math.max(-1, Math.min(1, samples[i] * gain));
|
buffer.writeInt16LE(Math.round(pcm[i] * 32768), 44 + i * 2);
|
||||||
buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
|
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,19 @@ for (const spec of SONGS) {
|
|||||||
transition: spec.transition ?? 0.5,
|
transition: spec.transition ?? 0.5,
|
||||||
seed: 1 + SONGS.indexOf(spec),
|
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) {
|
if (!CHECK_ONLY) {
|
||||||
writeFileSync(resolve(OUT, `${spec.name}.wav`),
|
writeFileSync(resolve(OUT, `${spec.name}.wav`),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const FORBIDDEN = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Directories whose output must be a pure function of (seed, params, frame).
|
// 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.
|
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
||||||
const ALLOWED = new Set(['engine/perf.js']);
|
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
|
// Rate params: anything the shader multiplies absolute time by must be
|
||||||
// flagged `rate: true`, which stops reactivity and drift from touching it.
|
// flagged `rate: true`, which stops reactivity and drift from touching it.
|
||||||
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
|
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
|
||||||
|
|||||||
@@ -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})`);
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { resolve } from 'path';
|
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.
|
* 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({
|
export default defineConfig({
|
||||||
plugins: [measurementWriter()],
|
plugins: [measurementWriter(), llmGatewayProxy()],
|
||||||
server: {
|
server: {
|
||||||
port: 5180,
|
port: 5180,
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
@@ -60,6 +303,7 @@ export default defineConfig({
|
|||||||
debug: resolve(process.cwd(), 'debug.html'),
|
debug: resolve(process.cwd(), 'debug.html'),
|
||||||
gallery: resolve(process.cwd(), 'gallery.html'),
|
gallery: resolve(process.cwd(), 'gallery.html'),
|
||||||
filmstrip: resolve(process.cwd(), 'filmstrip.html'),
|
filmstrip: resolve(process.cwd(), 'filmstrip.html'),
|
||||||
|
'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
@@ -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)?
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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._ -->
|
||||||
@@ -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>
|
||||||
Generated
+1058
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user