Root cause: the steady-state loop (#vid-loop) restarts only via a narrow
timeupdate window (last 0.08s -> seek to LOOP_TAIL_S), with native loop off.
Headless Chromium reliably lands a final timeupdate in that window, but under
real-browser decode/GPU load a timeupdate can skip past it; the clip then fires
'ended' and freezes on its last frame ('videos aren't looping'). There was no
recovery path. Add an 'ended' listener that seeks back to the tail and resumes,
guaranteeing the loop regardless of the timeupdate race. No change to normal
playback. Proven by a Playwright regression test that forces the ended state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
41 clips, 753 string-variants per language, structure-validated (key-sets
and tier-counts identical to en). Escalation semantics preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expanded-pools sky-scale work plus two experience tweaks:
- New `sky` altitude (airplane-window / low-flight layer) between orbit and
coast; RING_ORDER cosmos·orbit·sky·coast·reef·abyss. New clip pools across
all scales, re-baked the full adjacent-pair morph set (all-intra, LFS), and
removed the now-stale cosmos↔abyss transitions the old ring order produced.
- Auto-start: once the preload finishes the experience turns Video on and
raises the audio dial to 3/10 with no user gesture (app.js autoStart()).
- Dropped reef_hawaiian (text overlay — violates the text-free pool rule);
reef pool 7→6, manifest transitions 586→558.
Tests: rewrote tests/test_e2e_audio.py for the dial UI + auto-start; updated
the manifest morph-count assertions and the altitude-scrub target for the new
ring order. 302 pytest + 11 Playwright e2e + 9 node unit all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator: slow click/auto transitions 2x and make them the same speed between any two
altitudes (2 away should take twice as long as 1). autoScrub duration is now
PROPORTIONAL to distance (PER_ALTITUDE_MS=1200, ~2x the old fixed 600ms total): a
label-click or wheel jump of N altitudes takes N*1200ms, so per-step speed is constant.
Manual drag is unaffected (operator drives its speed). E2E asserts a 2-step jump is
~2x a 1-step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator: audio turned off sometimes after landing on a new altitude. Root cause:
fadeVolume started a setInterval ramp that was never cancelled. When restAudio faded
the idle element to 0 and — within FADE_MS — that same element became the newly-landed
ACTIVE scale (assignElements reuses an element per soundtrack url), the stale fade-to-0
kept running and dragged the now-active element back to silence. Fix: cancelFade()
supersedes any in-flight fade when a new fade starts (fadeVolume) or a direct volume
is set (ensurePlaying). E2E proves a new fade supersedes a stale one (fails without
the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator request. Replaces the Audio checkbox with a 0-10 range slider that sets the
master soundtrack gain (audioVol = level/10), scaling both the per-scale rest volume
and the two-track scrub crossfade. Level 0 = silent. Turning Video on for the first
time now raises audio to 3/10 (was: full on) so the experience opens gently; raising
the slider from 0 unlocks playback in-gesture (Safari). E2E: dial is range 0-10
default 0, and first video-on lifts it to 3/10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator eyeball: a jolt/pause when the morph ends and the next altitude's loop
loads. Root cause: swapping vid.src to the base + seeking to the tail reloaded the
ON-SCREEN element (decode + seek stall). Now a dedicated #vid-loop element carries
the steady-state base loop; during a scrub it is PRELOADED to the clip we're heading
toward, seeked to the tail and paused on that exact frame. The paint shader reads the
active source (busy ? morph : loop), so landing is an instant source swap — no reload,
no seek. E2E asserts the loop element is armed + playing from ~3s after landing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator eyeball feedback: the red snapper clip opened with a black 'Northern Red
Snapper / fishwatch.gov' title card. Re-trimmed reef_snapper/base.mp4 to drop the
first 3.0s (the card; footage is clean from 3.0s), 23s->20s, and re-baked the 7
reef_snapper morph pairs (+ reverses) all-intra from the clean base. Verified the
snapper-dominant morph frames are text-free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator eyeball feedback: landing on an altitude jump-cut the loop back to frame 0.
Each morph reproduces the destination clip's first 3s (pipeline trim=0:3) and ends on
dst@~3s, so the steady-state loop now starts at LOOP_TAIL_S=3.0 and loops within
[3.0, duration] — the morph's last frame hands straight off to the loop, and the
morph-covered intro never re-shows at rest (removes the overlapping 'altitude video
that is part of the morph'). E2E asserts a landed clip loops from ~3s, not 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 7. Every frame now a keyframe (verified 93/93 I-frames on a sample),
so the scrub interaction seeks to arbitrary frames smoothly. Total transitions
173M -> 504M; largest clip ~5.8M (well under the 25MB LFS/proxy ceiling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 7. Extracts transition_cmd/reverse_cmd argv builders and adds the
ALL_INTRA flag set (-g 1 -keyint_min 1 -sc_threshold 0) so every baked frame is a
keyframe — smooth arbitrary-frame seeking for the scrub interaction. Unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 5. Replaces the .rev-asserting zoom-out test with a turn-back test
(same forward morph seeked backward, audio recedes, re-locks the start clip); adds
a detent-crossing commit+lock test via a new window.__hefState diagnostic seam.
Full tier (6 tests) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 4. onWheel + jumpToScale now animate pos via autoScrub() (drives the
same setPos scrub path, ~600ms, lands exactly on the integer + locks). Removes the
now-dead advance()/playTransition()/FAST_BLEND_RATE (the server /api/ring/advance
round-trip is no longer used by the client). Post-landing morph warm-up folded into
setPos's settle branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 3. A continuous float pos is the knob: setPos() seeks the segment's
morph currentTime by frac (throttled one seek/rAF), crossfades audio, sweeps the
needle live, and commits+locks+re-rolls on each integer crossing. Drag live-scrubs
(no more commit-on-release); release holds the blend (no auto-complete). Client-side
pool pick replaces the server round-trip for drag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 2. Adds <audio id=aud-b>; blendAudio(lo,frac) mixes the two
adjacent scale soundtracks by knob fraction (element reuse across crossings so
only the genuinely-new scale reloads); restAudio settles a single scale at rest.
Diagnostic readout now reports whichever element is audible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 1. position->segment, frac->currentTime, audio gains, and
integer-crossing detection; 9 node --test cases green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dial needle now sweeps one detent per crossed altitude driven by the morph
video's playback progress, landing exactly when the morph ends and the clip
locks. Drops the live finger-follow during drag (which moved the needle to the
target ahead of the zoom and caused a snap-back on commit) so the needle moves
ONLY with the transition. Verified in Chromium + WebKit, both directions, wheel
and drag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The curated clip bases, per-scale audio, and 154 baked altitude morphs are the
actual experience content, so they're committed via git-LFS (self-hosted Gitea
LFS store). coast_birdrock/base.mp4 was re-encoded 68MB->22MB (25Mbps->8Mbps,
1080p30 unchanged) to fit the git.benstull.org front-end nginx upload limit; its
16 morphs were re-baked. Stale right_variants stay gitignored. Adds .gitattributes
(LFS) and negates the used media back into git in .gitignore. Also records the
LFS media-graduation decision in the plan doc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 8. New simulator/e2e Playwright tier (§9 browser tier). altitude-lock.spec
proves: zooming plays the morph matching the landed clip (forward + .rev), and
the clip is LOCKED after landing (no change while parked). advance() records
played morph paths on window.__hefMorphs (diagnostic seam; reliable under the
blob cache).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 7. advance() threads the current clip as from_clip_id, plays the
server-resolved chained morphs (each already lands on its destination clip),
then locks activeClipId until the next altitude change — no fade-to-black
secondary swap, no re-roll while parked. Builds a (from->to) morph lookup
from the ring; preloads only the morphs reachable from the locked clip,
refreshed per landing (154 morphs is too many to preload up front). Drops
reverseFile + fadeThroughBlack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 3-6. Transition is now a directed clip-pair morph; ScaleRing.morph_for
looks up (from,to)->file. advance_ring returns structural steps (no file);
resolve_move picks each crossed altitude's clip THEN its morph, chained, and
reports the locked target. Fast spin keeps the whole chain (blended) instead
of collapsing. /api/ring/advance takes from_clip_id, draws picks, returns the
resolved chain. Dropped ring_move_to_dict + _rev_file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 1-2 of the altitude clip-pair morph plan. Manifest emits a morph
entry for every video pair in adjacent altitudes, both directions
(<src>__<dst>.mp4 zoom-in + .rev zoom-out); generate_media() bakes them
all via the existing xfade=zoomin recipe keyed by clip member.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'no sound' debugging is resolved (stale-server resilience). Move the audio
status readout + native test player into the Dev Mode panel — kept for future
'can't hear' debugging, out of the installation UI. 7 E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The operator's video wouldn't turn on: their stale uvicorn (old Python) requires a
7-way 'content' and 422s the {visual,audio} payload, and returns {content:{video}}
not {render:{video:{shown}}}. So the alteration POST failed and video never updated
(audio works because it's client-driven). Fix: controls() also sends a back-compat
'content' field (current server ignores it; old one ignores visual/audio), and
update() reads video-shown from either response shape. Verified end-to-end against a
simulated fully-stale server: video shows + audio plays, zero errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of 'no sound', found via the live readout: the operator's /api/ring
returned the ring WITHOUT the per-scale audio field (a server process predating the
audio code) → soundtrackUrl() was null → the element never got a src (readyState 0,
no error). The native test player proved the file itself plays. Fix: client falls
back to a scale-id→soundtrack map (mirrors build_pool_manifest.SCALE_AUDIO) so audio
works without a server restart. Verified: stale-ring sim now plays; audio-first-then-
video leaves both on. +2 E2E regression tests (7 total). Chromium + WebKit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The operator's 'PAUSED readyState 0' (no error) means soundtrackUrl() returned null
— the running app's /api/ring has no audio for the scale, the signature of a STALE
uvicorn server (Python predates the audio code; static reload doesn't restart it).
Status now shows url=<…|NONE>, ring=server|SYNTH, scale id so it's unambiguous.
Fresh server verified: ▶ playing rs=4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three blind fixes failed because headless browsers relax autoplay (can't reproduce)
and play() errors were swallowed. Instrument instead:
- #audio-status: live readout (playing/paused/blocked/media-error) under the toggle
- surface the play() rejection reason + element error (was .catch(()=>{}))
- 'Audio check' fieldset with a native <audio controls> on the soundtrack file, to
test the file directly with zero app code in the path
Diagnostic only — no behavior change. 37 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator follow-ups:
- Remove the tap-to-start wall; show a 'Loading Universe…' splash until all media
preloaded (main() awaits preloadAllMedia + a progress bar), then reveal the app.
- Both Video + Audio toggles default OFF (black + silent at start).
- FIX audio still silent on real Safari: the A/B-crossfade-after-gesture didn't
unlock on Safari (priming srcless elements doesn't unlock them). Use ONE <audio>
element played SYNCHRONOUSLY inside the toggle's click — the reliable Safari
unlock; later altitude swaps reuse the unlocked element.
- First time Video turns on, also turn Audio on (one flip = full experience), set+
played in the same gesture; Audio-on-first stays audio-only.
- Soundtrack follows Altitude via the single element (brief fade swap).
- Verified in headless Chromium AND WebKit: coupling works, soundtrack plays,
video-off blanks, zero JS errors. 290 tests + 5 Playwright E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-driven live-UI revision:
- Video + Audio are now Dev-Mode-style on/off toggles (Audio on = soundtrack);
white-noise deferred (removed from the live control; pure machinery kept).
- FIX video-off not blanking: the <video>/<canvas> are GPU-composited and could
paint above the auto-z #black on a real GPU — give #black z-index + its own
layer AND hide the video layers (opacity 0) on video-off.
- FIX no soundtrack on Safari/iOS: Safari unlocks <audio> only when play() runs
INSIDE the user gesture; the start gesture now primes both A/B elements
synchronously, so the async crossfades are allowed to play.
- Toggles wired on 'change' (matches the Dev Mode switch).
- player/audio.py AUDIO_SOURCES -> {off, soundtrack}; tests + E2E updated.
- Verified in headless Chromium AND WebKit: audio plays, video-off blanks both
layers, zero JS errors. 288 tests + 3 Playwright E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2E asserts in a real headless Chromium: white_noise loads the noise asset,
soundtrack loads the current scale's asset, and Visual=off fades video while
audio keeps playing. Driving it out surfaced + fixed three client issues:
- fadeVolume uses setInterval (rAF stalls when the tab is backgrounded)
- start gesture drops srcless priming (a click grants Chrome autoplay
document-wide; priming empty <audio> only hung the handler / polluted state)
- applyAudio doesn't await play() (awaiting a srcless/buffering play can hang)
pyproject: [e2e] optional dep (pytest-playwright). Full suite 288 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure arg builders (audio_ops) + thin runner (audio_run) + build_audio_media.py.
Produces the 5 normalized soundtrack loops + a synthesized pink-noise bed under
the gitignored sample_media/audio tree. 6 tests pass (white-noise integration
runs against bundled imageio_ffmpeg).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sound for the experience, plus the design to control it.
Soundtrack layer (ambience), all license-clean (PD/CC0), recorded in
docs/audio-candidate-pool.md (media gitignored; re-sourceable):
- cosmos: Pillars of Creation sonification (NASA, PD)
- orbit: station room-tone hum (CC0)
- coast: sea waves + terns (CC0)
- reef: 6-layer NOAA soundscape + CC0 submerged bed (PD + CC0)
- abyss: blue-whale moan (NOAA, PD)
Solution Design (proposed): split the bundled Content selector into two
orthogonal dials, Visual (on/off) x Audio (off/soundtrack/white-noise),
unlocking white-noise+video. Soundtrack follows the single Altitude dial;
white noise is altitude-independent. Reframes the networked-control-surface
spec SS3/SS5 (still proposed/unbuilt -> clean pre-impl edit). Music layer deferred.
Dev panel: links to the clip + audio review galleries; sticky stage/panel layout.
gitignore: audio media, review_audio.html, .DS_Store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator wanted more Cosmic Cliffs footage and a different-color rendering.
Keeps the NIRCam flythrough as the cosmos primary and adds ESA/Webb weic2205c
("Pan of the Carina Nebula, combined NIRCam + MIRI") as a new cosmos pool
member — the same Carina towers in the muted gray/teal/gold MIRI mid-infrared
palette, distinct from the iconic orange/blue flythrough.
- POOLS["cosmos"] grows 4 -> 5 (cosmos, cosmos_miri, galaxies, hudf, xdf);
primary unchanged.
- META + LABELS authored (nebula/star/distance, salience-gated); affect inherits
the cosmos scale vocabulary.
- manifest.json regenerated (19 -> 20 clips).
- Source: 4K master, text-free/human-free, trim 4-26s (clears the fade-from-
black), 1 s crossfade-loop (~21 s) -> 1080p base + 4K master (gitignored).
- docs/content-candidate-pool.md updated (cosmos pool of 5 + sourcing note).
271 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The affect channel previously gated on BOTH knobs (strength = min(left, right)),
so the analytical Left brain co-controlled the emotion words. Per operator
direction, emotions are now driven by the Right (dreamlike) knob ALONE — Left no
longer gates them. This sharpens the split: Left = measurement (detections +
measurements), Right = the dream AND the feeling it evokes.
- player/alteration.py: `_affect_strength(right) = right` (was min(left,right));
`_affect_intensity` keyed off Right. AffectOverlay + is_identity docs updated
(is_identity still holds: right>0 ⇒ dream.strength>0).
- simulator/static/app.js: renderAffect comments (gating is Right via `strength`);
the client logic already keyed min_level/tier off the passed strength + right.
- Affect design spec: revision note + every min(left,right) → right.
- Tests rewritten: affect is Right-driven (full Right + Left 0 surfaces affect;
Right 0 + Left 4 surfaces none).
271 passed, 2 skipped. Live API verified: left=0/right=4 → strength 4; left=4/
right=0 → strength 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes to the simulator's media handling, both surfaced by the cosmos clip swap.
1. Permanent cache-bust via content hash. New `GET /api/media-versions` returns a
short sha1 token per served file (clip bases + ring transitions + reverses),
cached by (mtime, size) so a re-bake is picked up without a restart. The client
fetches it at boot and appends `?v=<hash>` to every /media URL, so a clip
re-baked under the same path (e.g. a re-sourced cosmos base) gets a fresh URL
the browser can't serve stale — fixing the recurring stale-clip problem at the
root (supersedes the `cache: "reload"` belt-and-suspenders, which stays).
2. Fade the pool-landing swap. The baked ring transition zooms into the scale
PRIMARY, but the rotating pool picks a random member on landing — so a
non-primary pick hard-cut from the primary to the chosen clip (e.g. coast
birdrock -> surfgrass, the artifact the operator saw). On landing, when the pick
differs from the primary, mask the swap behind a short fade through the existing
#black overlay (CSS opacity transition); same-clip landings still do a plain
(re)load. General across all pooled scales.
271 passed, 2 skipped (+2: media-version content-hash + absent-file omission).
app.js syntax-checked; endpoint verified live (29 files; token matches a manual
sha1 of the bytes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preload fetched each clip with the default HTTP cache, so a clip re-baked
under the same path (e.g. the cosmos base re-sourced to the Webb Cosmic Cliffs
flythrough) kept showing stale footage after a reload — worst case forever, when
a pre-`no-cache` build had pinned it `immutable, max-age=1y` (a plain reload never
revalidates an immutable entry). Fetch the preload with `cache: "reload"`, which
bypasses the HTTP cache, pulls fresh bytes, and updates the cache entry; the
in-memory blob cache still gives instant in-session altitude swaps. One fetch per
(re)load, so no steady-state cost.
269 passed, 2 skipped. app.js syntax-checked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator found the Orion SVS 30957 flythrough merely good and asked for a more
dramatic nebula. Replaces the cosmos-scale primary with STScI's "Exploring the
Cosmic Cliffs in 3D" (Carina Nebula, Webb NIRCam — NASA SVS 31348), sourced from
its 4K master (Clifs-3d-STScI.mp4, 3840x2160) via the content pipeline:
trim 10-34s (clears the title card ~8s and end-credit card ~102s) + crossfade-loop
-> 4K master + supersampled 1080p base (5.5 Mbps, crisper than the old 2.9 Mbps
1080p-native Orion encode).
- build_pool_manifest META: cosmos -> SVS 31348 Cosmic Cliffs, new CCBY_WEBB
credit (NASA, ESA, CSA, STScI). Labels retuned for the Carina framing
(emission nebula / protostar; redshift -> distance ~7,500 ly).
- Regenerated manifest.json (also corrects stale JPL-Orion provenance left when
129bb23 updated META but not the manifest) + all ring-edge transitions from the
new base (cosmos-orbit / abyss-cosmos now zoom from the Cosmic Cliffs).
- docs/content-candidate-pool.md: record the swap + history.
269 passed, 2 skipped. New base verified text-free + full-frame by-eye across the
loop; final look to be confirmed by the operator (no Chrome on the box).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the JPL "Orion: Dust and Death" primary (captioned throughout — we'd
cropped it, losing framing) with STScI's "Flight Through the Orion Nebula"
(visible light, NASA SVS 30957) — a full-frame, text-free flythrough of the same
nebula. Sourced via the content pipeline (trim 10–34s + crossfade-loop → 1080p
master + base); cosmos-orbit / abyss-cosmos transitions regenerated from it.
- build_pool_manifest META: cosmos -> SVS 30957, license CC-BY-class (NASA/ESA/
STScI), new trim window.
- Remove the now-obsolete decaption_cosmos() crop helper (the new source needs
no caption removal).
- docs/content-candidate-pool.md: record the source swap; drop the crop note.
269 passed, 2 skipped. New clip verified text-free + full-frame by-eye;
final look to be confirmed by the operator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `immutable, max-age=1y` media header pinned stale footage in the browser for
a year, so a re-sourced or re-cropped clip (e.g. the de-captioned cosmos) never
showed without a manual cache clear. Switch /media to `no-cache` (store, but
revalidate every load): instant altitude swaps already come from the in-memory
blob preload, so the HTTP cache only matters on (re)load — where a conditional
request is a cheap 304 when unchanged but picks up edited clips immediately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cosmos primary is NASA/JPL's "Orion: Dust and Death" — an annotated
visualization with lower-third captions ("DUST FILAMENT", "BLUE BUBBLE", …)
burned in throughout, so trimming can't avoid them (only ~4s of our window is
caption-free). The candidate-pool doc wrongly called it text-free.
- build_pool_manifest.decaption_cosmos(): crops the cosmos base/master to the
top 66% of frame height (16:9 preserved, center-x, ~1.5x tighter framing),
re-fit to 1920x1080 — the recorded, reproducible form of the fix (media is
gitignored). Applied in place; re-ran generate_media() so the cosmos-orbit /
abyss-cosmos transitions use the cropped base.
- docs/content-candidate-pool.md: correct the "text-free" note; document the crop.
269 passed, 2 skipped. De-caption verified by-eye on both caption moments;
final look to be confirmed by the operator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cosmos→orbit, reef→abyss and abyss→cosmos transition clips were stale
leftovers from an earlier 5-scale ring, built against old/placeholder footage —
so those edges flashed placeholder frames mid-zoom. Only orbit-coast and
coast-reef had been rebuilt from the current real bases.
- build_pool_manifest.generate_media(): now rebuilds EVERY ring edge from the
current real primary pool bases (the orbit-coast recipe applied uniformly),
overwriting the stale clips, and bakes a reversed companion (<edge>.rev.mp4)
per edge via an ffmpeg `reverse` pass.
- app.js: a `reversed` step (an ascending / zoom-OUT ring move) now plays the
baked .rev clip instead of the forward zoom-in, so going up recedes from the
current scale. Both directions are added to the in-memory preload set.
Media is gitignored (generated locally / on deploy); regenerated all 10 clips
locally. 269 passed, 2 skipped; node --check clean; clips serve 200.
Visual result to be verified by-eye by the operator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The generic .hidden { display:none } is defined earlier in the stylesheet than
.dev-panel { display:flex }; equal specificity meant source order won and the
panel stayed visible with the toggle off. A two-class .dev-panel.hidden rule
wins, so all dev controls/data truly hide when Dev Mode is disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a single "Dev Mode" switch at the bottom of the control panel (off by
default, persisted in localStorage). When on, a panel appears below it with all
dev controls and data; when off the default view is just the experience + the
three control fieldsets.
Dev panel:
- Pool picker: a <select> of the current altitude's pool members — choose any
clip to override the random landing pick; a "Re-roll random" button re-runs
the server-canonical pick. Rebuilds on each landing.
- Active clip metadata (id, title, source, license, base_file).
- Annotations & affect: keys with salience/window/tier counts (read-only;
editing stays in /author.html).
- RenderPlan JSON — relocated here from the always-visible panel.
- Cache & playback: preload status (N/total), memory-vs-network for the active
clip, live resolution/duration/loop position.
Frontend only — no API changes; everything is read from data the renderer
already has. Factored pickRandomMember() out of landScale() so the initial
landing, ring advances, and the re-roll button share one pick.
Design: docs/superpowers/specs/2026-06-25-simulator-dev-mode-design.md
269 passed, 2 skipped; node --check + server smoke clean. Frontend behavior
to be verified by-eye by the operator (no headless browser in repo yet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>