@@ -7,8 +7,22 @@
const $ = ( id ) => document . getElementById ( id ) ;
const vid = $ ( "vid" ) , paint = $ ( "paint" ) , tint = $ ( "tint" ) , overlay = $ ( "overlay" ) ,
black = $ ( "black" ) , readout = $ ( "readout" ) ;
const loopVid = $ ( "vid-loop" ) ; // steady-state base loop element (double-buffer; #vid carries the scrub morphs)
const affectLayer = $ ( "affect" ) ;
// The currently-displayed source element: the morph (#vid) while scrubbing (busy),
// else the preloaded base loop (#vid-loop). The paint shader + fallback read this so
// a landing is a source swap, never a reload of the on-screen element.
function displayVid ( ) { return busy ? vid : loopVid ; }
// Show the active source, hide the other (matters only in the WebGL-less fallback;
// with the paint canvas it composites the active source anyway).
function showActiveSource ( ) {
const a = displayVid ( ) ;
a . style . opacity = "1" ;
( a === vid ? loopVid : vid ) . style . opacity = "0" ;
}
// Right-dream state, read by the painterly render loop each frame. update() (debounced
// on knob moves) writes these; the WebGL loop renders the live video continuously.
let dreamIntensity = 0 ; // 0..1 — painterly strength (and dream pastel/luminous)
@@ -222,31 +236,39 @@ function preloadChip() {
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP _TAIL _S = 3.0 ;
function seekToLoopStart ( ) {
try { vid . currentTime = LOOP _TAIL _S ; } catch ( e ) { /* not seekable yet */ }
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop ( clip ) {
if ( ! clip || loopVid . dataset . clip === clip . id ) return ;
loopVid . dataset . clip = clip . id ;
loopVid . dataset . loopTail = "1" ; // arm the [LOOP_TAIL_S, duration] wrap handler
loopVid . src = mediaUrl ( clip . base _file ) ;
loopVid . loop = false ; // native loop restarts at 0; we loop from the tail instead
loopVid . muted = true ;
const seek = ( ) => { try { loopVid . currentTime = LOOP _TAIL _S ; } catch ( e ) { /* not seekable yet */ } } ;
if ( loopVid . readyState >= 1 ) seek ( ) ;
else loopVid . addEventListener ( "loadedmetadata" , seek , { once : true } ) ;
}
function playLoop ( ) {
if ( loopVid . paused ) loopVid . play ( ) . catch ( ( ) => { } ) ;
}
function ensureClipMedia ( ) {
const clip = activeClip ( ) ;
if ( ! clip || clip . id === currentClipId ) return ;
if ( ! clip ) return ;
currentClipId = clip . id ;
delete vid . dataset . morph ; // this is a base loop, not a morph
vid . dataset . loopTail = "1" ; // arm the [LOOP_TAIL_S, duration] loop handler
vid . src = mediaUrl ( clip . base _file ) ;
vid . loop = false ; // native loop restarts at 0; we loop from the tail instead
vid . muted = true ;
if ( vid . readyState >= 1 ) seekToLoopStart ( ) ;
else vid . addEventListener ( "loadedmetadata" , seekToLoopStart , { once : true } ) ;
vid . play ( ) . catch ( ( ) => { } ) ;
vid . style . opacity = "1" ;
loadLoop ( clip ) ; // idempotent: a no-op if the scrub already preloaded this clip
playLoop ( ) ; // steady state runs the loop
}
// The custom loop: when a base loop reaches the end, jump back to the tail offset
// (not 0). Inert during a morph (loopTail cleared in rebuildSegment), where the
// scrub drives currentTime directly.
vid . addEventListener ( "timeupdate" , ( ) => {
if ( vid . dataset . loopTail === "1" && vid . duration && vid . currentTime >= vid . duration - 0.08 ) {
seekToLoopStart ( ) ;
// The custom loop on the LOOP element : when a base loop reaches the end, jump back to
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
loopVid . addEventListener ( "timeupdate" , ( ) => {
if ( loopVid . dataset . loopTail === "1" && loopVid . duration && loopVid . currentTime >= loopVid . duration - 0.08 ) {
try { loopVid . currentTime = LOOP _TAIL _S ; } catch ( e ) { /* not seekable */ }
}
} ) ;
@@ -263,8 +285,8 @@ function applyVideoLook(tone, dream) {
gradeFilter = ` brightness( ${ bright . toFixed ( 3 ) } ) saturate( ${ sat . toFixed ( 3 ) } ) sepia( ${ sepia . toFixed ( 3 ) } ) ` ;
dreamIntensity = dream ;
tint . style . opacity = ( cool * 0.6 ) . toFixed ( 3 ) ;
// WebGL-less fallback: the canvas is hidden, so grade the visible #vid directly.
if ( ! paintOK ) vid . style . filter = gradeFilter ;
// WebGL-less fallback: the canvas is hidden, so grade the visible source directly.
if ( ! paintOK ) displayVid ( ) . style . filter = gradeFilter ;
}
// --- Right dream: real-time painterly restyle (WebGL2 Kuwahara) ---
@@ -352,11 +374,12 @@ function initPaint() {
return true ;
}
function paintLoop ( ) {
if ( paintOK && vid . readyState >= 2 && vid . videoWidth ) {
const w = vid . videoWidth , h = vid . videoHeight ;
const src = displayVid ( ) ; // morph while scrubbing, else the preloaded base loop
if ( paintOK && src . readyState > = 2 && src . videoWidth ) {
const w = src . videoWidth , h = src . videoHeight ;
if ( paint . width !== w || paint . height !== h ) { paint . width = w ; paint . height = h ; }
gl . viewport ( 0 , 0 , w , h ) ;
try { gl . texImage2D ( gl . TEXTURE _2D , 0 , gl . RGB , gl . RGB , gl . UNSIGNED _BYTE , vid ) ; } catch ( _ ) { }
try { gl . texImage2D ( gl . TEXTURE _2D , 0 , gl . RGB , gl . RGB , gl . UNSIGNED _BYTE , src ) ; } catch ( _ ) { }
// No painterly restyle during a ring transition (busy) — show it raw.
gl . uniform1f ( uAmt , busy ? 0 : dreamIntensity ) ;
gl . uniform2f ( uTexel , 1 / w , 1 / h ) ;
@@ -440,7 +463,8 @@ function boxAt(a, t) {
// Loop-normalized playback time of the base video (0..1), for track interpolation.
function loopT ( ) {
return vid && vid . duration ? ( vid . currentTime % vid . duration ) / vid . duration : 0 ;
const v = displayVid ( ) ;
return v && v . duration ? ( v . currentTime % v . duration ) / v . duration : 0 ;
}
// --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) ---
@@ -561,7 +585,7 @@ function controls() {
const mood = + $ ( "mood" ) . value ;
return {
visual : $ ( "visual" ) . checked ? "on" : "off" ,
audio : $ ( "audio" ) . checked ? "soundtrack" : "off" ,
audio : audioLevel ( ) > 0 ? "soundtrack" : "off" ,
// Back-compat: a server process predating the visual/audio split still requires
// a 7-way `content` and would 422 the new payload. Send both — the current
// server ignores `content`, an old one ignores visual/audio.
@@ -591,11 +615,12 @@ async function update() {
// the <video>/<canvas> are GPU-composited and can otherwise show through the
// overlay on a real GPU (Safari/Chrome).
black . style . opacity = "1" ; black . classList . remove ( "hidden" ) ;
vid . style . opacity = "0" ; paint . style . opacity = "0" ;
vid . style . opacity = "0" ; loopVid . style . opacity = "0" ; paint . style . opacity = "0" ;
return ;
}
black . classList . add ( "hidden" ) ;
vid . style . opacity = "" ; paint . style . opacity = "" ; // restore the video layer s
paint . style . opacity = "" ; // restore the painterly canva s
showActiveSource ( ) ; // show the active source (WebGL-less fallback)
try {
ensureClipMedia ( ) ;
@@ -660,6 +685,7 @@ let dialDrag = null; // {lastAng, accum, moved, restPos, target} while
let pos = 0 ; // continuous knob position; rest value == ringIndex
let activeSeg = null ; // { lo, clipLo, clipHi, file } for the segment being scrubbed
let seekPending = false ; // throttle: at most one currentTime seek per animation frame
let scrubDir = 1 ; // last travel direction (+1 descend / -1 ascend) — which end we'll land on
// Diagnostic seam (sibling of window.__hefMorphs): the committed altitude state, so
// E2E can assert the locked clip mid-scrub when #scale-name (settle-only) is stale.
@@ -755,8 +781,6 @@ function rebuildSegment(lo, enteredFrom) {
tint . style . opacity = "0" ;
vid . style . filter = "none" ;
vid . loop = false ;
vid . dataset . loopTail = "0" ; // morph scrub drives currentTime; disarm the base loop handler
vid . style . opacity = "1" ;
if ( vid . dataset . morph !== file ) {
vid . dataset . morph = file ;
vid . src = mediaUrl ( file ) ;
@@ -774,6 +798,8 @@ function rebuildSegment(lo, enteredFrom) {
function setPos ( next ) {
if ( ! ring || ring . scales . length < 2 ) return ;
const n = ring . scales . length ;
const dir = next > pos ? 1 : ( next < pos ? - 1 : scrubDir ) ; // travel direction (for which end we'll land on)
scrubDir = dir ;
for ( const c of HEFScrub . integerCrossings ( pos , next ) ) {
ringIndex = HEFScrub . wrapIndex ( c . index , n ) ;
if ( activeSeg ) {
@@ -787,15 +813,22 @@ function setPos(next) {
busy = false ;
delete vid . dataset . morph ; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null ; // force ensureClipMedia to reload + loop the locked clip
playLoop ( ) ; // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
showActiveSource ( ) ;
setNeedle ( ringIndex * dialStep ( ) ) ;
renderScaleReadout ( ) ;
update ( ) ; // ensureClipMedia loads + loops the locked clip
update ( ) ; // ensureClipMedia: idempotent loadLoop + keep the loop running
restAudio ( ringIndex ) ;
refreshReachablePreload ( ) ; // warm the morphs now reachable from the locked clip
return ;
}
busy = true ; // mid-morph: block update() from reloading the base clip under us
if ( ! activeSeg || activeSeg . lo !== lo ) rebuildSegment ( lo , ringIndex ) ;
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
const headingId = dir > 0 ? activeSeg . clipHi : activeSeg . clipLo ;
loadLoop ( clipsById [ headingId ] ) ;
showActiveSource ( ) ; // morph element is the live source while scrubbing
setNeedle ( pos * dialStep ( ) ) ;
blendAudio ( lo , frac ) ;
if ( activeSeg . file && ! seekPending ) { // throttle seeks to one per frame (avoid seek thrash)
@@ -942,8 +975,9 @@ function renderDevStats() {
const total = preloadList ( ) . length ;
const cached = Object . keys ( mediaBlobs ) . length ;
const fromMem = ! ! ( c && mediaBlobs [ c . base _file ] ) ;
const res = vid . videoWidth ? ` ${ vid . videoWidth } × ${ vid . videoHeight } ` : "—" ;
const dur = vid . duration ? ` ${ vid . duration . toFixed ( 1 ) } s ` : "—" ;
const dv = displayVid ( ) ;
const res = dv . videoWidth ? ` ${ dv . videoWidth } × ${ dv . videoHeight } ` : "—" ;
const dur = dv . duration ? ` ${ dv . duration . toFixed ( 1 ) } s ` : "—" ;
const loop = vid . duration ? ` ${ Math . round ( loopT ( ) * 100 ) } % ` : "—" ;
dl . innerHTML =
` <dt>cache</dt><dd> ${ cached } / ${ total } cached</dd> ` +
@@ -1000,6 +1034,15 @@ const audB = $("aud-b"); // second element: the two crossfade by
let audLastErr = "" ; // last play() rejection / element error (for the readout)
const FADE _MS = 500 ;
// Audio is a 0– 10 level dial (was an on/off toggle). 0 = silent. The level is the
// MASTER gain the per-scale soundtrack / crossfade is scaled by.
function audioLevel ( ) { return + $ ( "audio" ) . value || 0 ; }
function audioVol ( ) { return HEFScrub . clamp01 ( audioLevel ( ) / 10 ) ; }
function updateAudioLevelLabel ( ) {
const el = $ ( "audio-level-val" ) ;
if ( el ) el . textContent = String ( audioLevel ( ) ) ;
}
// The element currently carrying the most signal — for the diagnostic readout,
// which used to assume a single `aud`. With two crossfading elements either may be
// the audible one, so report on whichever is louder (ties → aud).
@@ -1008,11 +1051,11 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD _ERR = [ "" , "aborted" , "network" , "decode" , "format-not-supported" ] ;
function audioStatusText ( ) {
if ( ! $ ( "audio" ) . checked ) return "audio: off" ;
if ( audioLevel ( ) === 0 ) return "audio: off (level 0) " ;
const s = ring && ring . scales [ ringIndex ] ;
const url = soundtrackUrl ( ) ;
const el = activeAudEl ( ) ;
const head = ` ON · scale=${ s ? s . id : "?" } · ring= ${ serverRing ? "server" : "SYNTH" } · url= ${ url || "NONE" } ` ;
const head = ` L ${ audioLevel ( ) } /10 · scale=${ s ? s . id : "?" } · ring= ${ serverRing ? "server" : "SYNTH" } · url= ${ url || "NONE" } ` ;
if ( ! url ) return "audio: " + head + " ← no soundtrack URL for this scale" ;
if ( el . error ) return ` audio: ${ head } · MEDIA ERROR ${ el . error . code } ( ${ AUD _ERR [ el . error . code ] || "?" } ) ` ;
if ( audLastErr ) return ` audio: ${ head } · ${ audLastErr } ` ;
@@ -1024,9 +1067,10 @@ function updateAudioStatus() {
if ( ! el ) return ;
el . textContent = audioStatusText ( ) ;
const a = activeAudEl ( ) ;
const playing = $ ( "audio" ) . checked && ! ! soundtrackUrl ( ) && ! a . paused && ! a . error && ! audLastErr ;
const on = audioLevel ( ) > 0 ;
const playing = on && ! ! soundtrackUrl ( ) && ! a . paused && ! a . error && ! audLastErr ;
el . classList . toggle ( "playing" , playing ) ;
el . classList . toggle ( "blocked" , $ ( "audio" ) . checked && ( ! soundtrackUrl ( ) || ! ! a . error || ! ! audLastErr ) ) ;
el . classList . toggle ( "blocked" , on && ( ! soundtrackUrl ( ) || ! ! a . error || ! ! audLastErr ) ) ;
}
aud . addEventListener ( "error" , updateAudioStatus ) ;
audB . addEventListener ( "error" , updateAudioStatus ) ;
@@ -1098,22 +1142,23 @@ function assignElements(urlLo, urlHi) {
return { elLo : aud , elHi : audB } ;
}
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac).
// No-op when the Audio toggle is off .
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
// both scaled by the master audio level. No-op when the level is 0 .
function blendAudio ( loIndex , frac ) {
if ( ! $ ( "audio" ) . checked ) return ;
const lvl = audioVol ( ) ;
if ( lvl === 0 ) return ;
const urlLo = scaleAudioUrl ( loIndex ) , urlHi = scaleAudioUrl ( loIndex + 1 ) ;
const g = HEFScrub . crossfadeGains ( frac ) ;
const { elLo , elHi } = assignElements ( urlLo , urlHi ) ;
ensurePlaying ( elLo , urlLo , g . from ) ;
ensurePlaying ( elHi , urlHi , g . to ) ;
ensurePlaying ( elLo , urlLo , g . from * lvl ) ;
ensurePlaying ( elHi , urlHi , g . to * lvl ) ;
updateAudioStatus ( ) ;
}
// At rest on a single altitude: the element already holding it st ays at full gain,
// the other fades out. Off → both fade to silence.
// At rest on a single altitude: the element holding it pl ays at the master level, the
// other fades out. Level 0 → both fade to silence.
function restAudio ( index ) {
if ( ! $ ( "audio" ) . checked ) {
if ( audioLevel ( ) === 0 ) {
fadeVolume ( aud , 0 , FADE _MS , ( ) => aud . pause ( ) ) ;
fadeVolume ( audB , 0 , FADE _MS , ( ) => audB . pause ( ) ) ;
return ;
@@ -1121,7 +1166,7 @@ function restAudio(index) {
const url = scaleAudioUrl ( index ) ;
const active = ( audB . dataset . url === url ) ? audB : aud ;
const idle = active === audB ? aud : audB ;
ensurePlaying ( active , url , 1 ) ;
ensurePlaying ( active , url , audioVol ( ) ) ;
fadeVolume ( idle , 0 , FADE _MS ) ;
updateAudioStatus ( ) ;
}
@@ -1154,16 +1199,18 @@ async function main() {
renderScaleReadout ( ) ;
// Sliders stream on "input"; the toggles fire on "change".
for ( const id of [ "left" , "right" , "mood" ] ) $ ( id ) . addEventListener ( "input" , debounced ) ;
// Audio toggle: start/stop the soundtrack SYNCHRONOUSLY in this gesture so it
// unlocks + plays on Safari (which blocks play() outside a user gesture).
$ ( "audio" ) . addEventListener ( "change" , ( ) => { applyAudio ( ) ; debounced ( ) ; } ) ;
// Video toggle: the FIRST time video turns on, bring audio with it (one flip = the
// full experience). Audio toggled on its own first stays audio-only. The audio is
// set + played in THIS gesture so it unlocks on Safari.
// Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
// input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
// play() outside a user gesture).
$ ( "audio" ) . addEventListener ( "input" , ( ) => { updateAudioLevelLabel ( ) ; applyAudio ( ) ; debounced ( ) ; } ) ;
updateAudioLevelLabel ( ) ;
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
// flip = the full experience at a gentle level). If audio is already raised, leave it.
// Set + played in THIS gesture so it unlocks on Safari.
$ ( "visual" ) . addEventListener ( "change" , ( ) => {
if ( $ ( "visual" ) . checked && ! videoEverOn ) {
videoEverOn = true ;
if ( ! $ ( "audio" ) . checked ) { $ ( "audio" ) . checked = true ; applyAudio ( ) ; }
if ( audioLevel ( ) === 0 ) { $ ( "audio" ) . value = "3" ; updateAudioLevelLabel ( ) ; applyAudio ( ) ; }
}
debounced ( ) ;
} ) ;