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>
Plan for the approved design at
docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md.
Two phases: (1) scrub interaction against current morphs, (2) all-intra re-bake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the altitude clip-pair morph transition system (154 directional morphs,
pick-then-morph, chained, lock-per-altitude), graduated experience media into
git-LFS, the Playwright E2E tier, and the dial-needle sweep. Also lands the
approved design spec for the next increment (scrub-driven transitions).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approved design for the next increment: continuous knob position scrubs the morph
video + crossfades audio (hold-partway, reversible, re-roll-on-fresh-approach,
wheel auto-scrubs one altitude). Includes morph all-intra re-bake for smooth
seeking. To be planned + built in a separate session.
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>
Choose the destination clip before transitioning, play the matching
member->member morph (154 clips, all adjacent video pairs, both
directions), chain through each crossed altitude, and lock the clip
until the altitude is changed (no secondary swap, no re-roll).
Session 0024 (writing-plans). Stops at the review gate.
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>
Removes the tap-to-start wall (Loading Universe splash instead), fixes audio still
silent on real Safari (single <audio> played synchronously in the toggle gesture),
both toggles default off, first Video-on couples Audio. Verified Chromium + WebKit.
290 tests + 5 Playwright E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>