/**
 * GENE Motion — Scroll Hero: front-end behavior (viewScript).
 *
 * A video background scrubs with scroll while the hero is pinned; with more than
 * one heading (Pro) they also swap one at a time. Everything is window-based
 * (ancestor-independent) and CSS-sticky when the pin can hold — engaged only
 * when it actually can:
 *   - reduced motion / no JS → the plain hero (looping video, heading shown).
 *   - a clipping/transform ancestor that would break the pin (pinBreakKind) →
 *     same plain hero, never a broken pin.
 * transform / opacity / video.currentTime only. No libraries. never-break.
 *
 * End-of-scroll shatter (data-shatter="1"): over the last `--shatter-zone` of the
 * runway the frozen last video frame is captured to a canvas, cut into shards,
 * and flown apart with the scroll (scroll back → it reassembles). Free ships a
 * well-tuned fixed zone; Pro can tune it. If the frame can't be captured the
 * video simply holds — never a broken effect.
 */
import { prefersReducedMotion, clamp, lerp, pinBreakKind } from '../shared/motion';
import { ENTER, EXIT, RISE, SLIDE } from '../shared/hero-timeline';

const SCRUB_EASE = 0.16;

function smooth( t ) {
	t = t < 0 ? 0 : t > 1 ? 1 : t;
	return t * t * ( 3 - 2 * t );
}

// Split (Pro): the frame is cut into vertical slats that fly apart from the
// centre. Each slat is a full-frame div clipped to its column, so the frame
// stays seamless until it breaks.
function buildSlats() {
	const n = 9, out = [], mid = ( n - 1 ) / 2;
	for ( let i = 0; i < n; i++ ) {
		const left = ( i / n ) * 100;
		const d = mid ? ( i - mid ) / mid : 0; // -1 (left) .. +1 (right) from centre
		// The slat's box IS its column — no clip-path needed (the box clips).
		out.push( { clip: null, x0: left, x1: left + 100 / n, y0: 0, y1: 100, d: d, delay: Math.abs( d ) * 0.28 } );
	}
	return out;
}

// A radial fracture: rings × angular sectors from an impact point, low-left.
function buildShards() {
	const cx = 48, cy = 50, rings = [ 0, 12, 26, 44, 72 ], sectors = 13, out = [];
	let seed = 7;
	const rnd = () => {
		seed = ( seed * 9301 + 49297 ) % 233280;
		return seed / 233280;
	};
	const pol = ( rp, a ) => [ cx + Math.cos( a ) * rp * 1.15, cy + Math.sin( a ) * rp ];
	for ( let r = 0; r < rings.length - 1; r++ ) {
		for ( let s = 0; s < sectors; s++ ) {
			const j = ( rnd() - 0.5 ) * 0.1;
			const a0 = ( s / sectors + ( r % 2 ? 0.04 : 0 ) ) * 6.2832 + j;
			const a1 = ( ( s + 1 ) / sectors + ( r % 2 ? 0.04 : 0 ) ) * 6.2832 + j;
			const ri = rings[ r ], ro = rings[ r + 1 ];
			const pts = [ pol( ri, a0 ), pol( ro, a0 ), pol( ro, a1 ), pol( ri, a1 ) ];
			const clip = 'polygon(' + pts.map( ( p ) => p[ 0 ].toFixed( 2 ) + '% ' + p[ 1 ].toFixed( 2 ) + '%' ).join( ',' ) + ')';
			const xs = pts.map( ( p ) => p[ 0 ] ), ys = pts.map( ( p ) => p[ 1 ] );
			const mid = ( a0 + a1 ) / 2, mr = ( ri + ro ) / 2;
			out.push( {
				clip,
				x0: Math.min.apply( null, xs ),
				x1: Math.max.apply( null, xs ),
				y0: Math.min.apply( null, ys ),
				y1: Math.max.apply( null, ys ),
				dx: Math.cos( mid ),
				dy: Math.sin( mid ),
				dist: 0.5 + mr / 50,
				rot: ( rnd() - 0.5 ) * 170,
				rx: ( rnd() - 0.5 ) * 150,
				ry: ( rnd() - 0.5 ) * 150,
				dz: ( rnd() - 0.5 ) * 2,
				delay: mr / 150,
			} );
		}
	}
	return out;
}

// Capture a video's current frame and cut it into shards (glass) or slats
// (split). Returns { layer, shards } ready to append, or null if the frame
// can't be read (tainted / undecoded) — the caller then just holds the video.
// Shared by the single hero (end-of-scroll break) and multi-scene (chapter
// hand-off), so both break exactly the same way.
function captureShatter( video, splitStyle ) {
	if ( ! video ) return null;
	let url = '';
	try {
		const vw = video.videoWidth || 0, vh = video.videoHeight || 0;
		if ( ! vw || ! vh ) return null;
		// Cap the capture: the pieces are flying debris, so a slightly softer
		// frame is imperceptible — but a full 4K capture is megabytes of base64
		// and a huge decode, which is exactly what kills a phone tab.
		const scale = Math.min( 1, 1600 / vw, 1600 / vh );
		const w = Math.max( 2, Math.round( vw * scale ) );
		const h = Math.max( 2, Math.round( vh * scale ) );
		const c = document.createElement( 'canvas' );
		c.width = w;
		c.height = h;
		c.getContext( '2d' ).drawImage( video, 0, 0, w, h );
		url = c.toDataURL( 'image/jpeg', 0.82 );
	} catch ( e ) {
		return null;
	}
	if ( ! url ) return null;
	const shards = splitStyle ? buildSlats() : buildShards();
	const layer = document.createElement( 'div' );
	layer.className = 'gene-hero__shatter';
	// The frame is held ONCE, as a custom property on the layer; every shard
	// paints from it via the stylesheet. (Inlining the base64 URL on each of
	// ~50 shards used to put megabytes of style text in the DOM.)
	layer.style.setProperty( '--gene-shatter-img', 'url("' + url + '")' );
	shards.forEach( ( s ) => {
		// Each piece is only as big as its own clip's bounding box (clamped to
		// the frame), with the shared frame aligned behind it — instead of ~50
		// stacked full-screen will-change layers, whose combined backing stores
		// were crashing mobile browsers at the moment the break built. Total
		// layer area is now ~one frame regardless of shard count.
		const x0 = Math.max( 0, s.x0 ), y0 = Math.max( 0, s.y0 );
		const x1 = Math.min( 100, s.x1 ), y1 = Math.min( 100, s.y1 );
		const bw = x1 - x0, bh = y1 - y0;
		if ( bw <= 0 || bh <= 0 ) return; // piece lies entirely outside the frame
		const d = document.createElement( 'div' );
		d.className = 'gene-hero__shard';
		d.style.left = x0.toFixed( 3 ) + '%';
		d.style.top = y0.toFixed( 3 ) + '%';
		d.style.width = bw.toFixed( 3 ) + '%';
		d.style.height = bh.toFixed( 3 ) + '%';
		d.style.backgroundSize = ( 10000 / bw ).toFixed( 3 ) + '% ' + ( 10000 / bh ).toFixed( 3 ) + '%';
		d.style.backgroundPosition =
			( bw >= 100 ? 0 : ( x0 / ( 100 - bw ) ) * 100 ).toFixed( 3 ) + '% ' +
			( bh >= 100 ? 0 : ( y0 / ( 100 - bh ) ) * 100 ).toFixed( 3 ) + '%';
		if ( s.clip ) {
			// Re-base the polygon from frame-% to this box's own %.
			const re = s.clip.replace( /(-?[\d.]+)%\s+(-?[\d.]+)%/g, function ( _, px, py ) {
				return ( ( ( parseFloat( px ) - x0 ) / bw ) * 100 ).toFixed( 2 ) + '% ' +
					( ( ( parseFloat( py ) - y0 ) / bh ) * 100 ).toFixed( 2 ) + '%';
			} );
			d.style.clipPath = re;
			d.style.webkitClipPath = re;
		}
		// Fly-out distances are authored in frame-%; convert to this box's %.
		s.kx = 100 / bw;
		s.ky = 100 / bh;
		s.el = d;
		layer.appendChild( d );
	} );
	return { layer: layer, shards: shards.filter( ( s ) => s.el ) };
}

// Glass: staggered from the impact point, then each shard bursts in 3D —
// flying out, tumbling, and rushing toward / away from the camera.
function applyShatterTo( shards, f ) {
	if ( ! shards ) return;
	const e = smooth( f );
	for ( let i = 0; i < shards.length; i++ ) {
		const s = shards[ i ];
		const m = clamp( ( e - s.delay * 0.35 ) / ( 1 - s.delay * 0.35 ), 0, 1 );
		// Distances are authored in frame-%; kx/ky convert to the shard's own box
		// (each piece is now only its clip's bounding box, not a full-frame layer).
		const tx = s.dx * s.dist * 62 * m * ( s.kx || 1 );
		const ty = ( s.dy * s.dist * 62 * m + 36 * m * m ) * ( s.ky || 1 ); // gravity drags the debris down
		const tz = s.dz * 380 * m;                       // depth burst toward / away from the camera
		s.el.style.transform =
			'translate3d(' + tx.toFixed( 2 ) + '%,' + ty.toFixed( 2 ) + '%,' + tz.toFixed( 1 ) + 'px) ' +
			'rotateX(' + ( s.rx * m ).toFixed( 1 ) + 'deg) rotateY(' + ( s.ry * m ).toFixed( 1 ) + 'deg) rotateZ(' + ( s.rot * m ).toFixed( 1 ) + 'deg)';
		s.el.style.opacity = ( 1 - clamp( ( m - 0.8 ) / 0.2, 0, 1 ) ).toFixed( 3 );
	}
}

// Split: slats fly outward from the centre (staggered) and fade.
function applySplitTo( shards, f ) {
	if ( ! shards ) return;
	const e = smooth( f );
	for ( let i = 0; i < shards.length; i++ ) {
		const s = shards[ i ];
		const m = clamp( ( e - s.delay ) / ( 1 - s.delay ), 0, 1 );
		const tx = s.d * 135 * m * ( s.kx || 1 ); // authored in frame-%; kx converts to the slat's own width
		const ty = 8 * m * m * ( s.ky || 1 );
		s.el.style.transform =
			'translate3d(' + tx.toFixed( 2 ) + '%,' + ty.toFixed( 2 ) + '%,0) rotate(' +
			( s.d * 7 * m ).toFixed( 2 ) + 'deg)';
		s.el.style.opacity = ( 1 - clamp( ( m - 0.55 ) / 0.45, 0, 1 ) ).toFixed( 3 );
	}
}

// --- Free-placement timeline (Pro) ------------------------------------------
// Shared by the single hero and multi-scene chapters: read placed headings from
// the DOM (save wrote each beat's placement / window / effect), pin them at
// their center-anchored (x%,y%) — phones can override — and drive each one
// through enter → hold → exit by a 0..1 scroll fraction. Pure functions of the
// fraction, so everything is reversible.
function readTimelineBeats( container, sharedAnim ) {
	const els = Array.prototype.slice.call( container.querySelectorAll( '.gene-hero__beat' ) );
	return els.map( function ( el ) {
		const num = function ( a, d ) {
			const v = parseFloat( el.getAttribute( a ) );
			return isFinite( v ) ? v : d;
		};
		const anim = el.getAttribute( 'data-anim' ) || sharedAnim;
		return {
			el: el,
			x: num( 'data-x', 50 ),
			y: num( 'data-y', 50 ),
			xM: el.hasAttribute( 'data-xm' ) ? num( 'data-xm', 50 ) : null,
			yM: el.hasAttribute( 'data-ym' ) ? num( 'data-ym', 50 ) : null,
			in: clamp( num( 'data-in', 0 ) / 100, 0, 1 ),
			out: clamp( num( 'data-out', 100 ) / 100, 0, 1 ),
			anim: anim,
			side: num( 'data-x', 50 ) < 50 ? -1 : 1,
			hadClip: false,
		};
	} );
}
function placeTimelineBeats( tbeats ) {
	const small = window.matchMedia && window.matchMedia( '(max-width: 781px)' ).matches;
	for ( let i = 0; i < tbeats.length; i++ ) {
		const t = tbeats[ i ];
		const x = small && t.xM !== null ? t.xM : t.x;
		const y = small && t.yM !== null ? t.yM : t.y;
		t.el.style.left = x + '%';
		t.el.style.top = y + '%';
		t.side = x < 50 ? -1 : 1;
	}
}
function driveTimelineBeats( tbeats, sp ) {
	for ( let i = 0; i < tbeats.length; i++ ) {
		const t = tbeats[ i ];
		const es = t.in - ENTER, ee = t.out + EXIT;
		let vis, travel;
		if ( sp <= es ) { vis = 0; travel = 1; }
		else if ( sp < t.in ) { const a = ENTER > 0 ? ( sp - es ) / ENTER : 1; vis = a; travel = 1 - a; }
		else if ( sp <= t.out ) { vis = 1; travel = 0; }
		else if ( sp < ee ) { const b = EXIT > 0 ? ( sp - t.out ) / EXIT : 1; vis = 1 - b; travel = -b; }
		else { vis = 0; travel = -1; }
		let tx = 0, ty = 0;
		if ( t.anim === 'rise' ) ty = travel * RISE;
		else if ( t.anim === 'slide' ) tx = travel * SLIDE * t.side;
		t.el.style.opacity = vis.toFixed( 3 );
		// Only a fully-shown heading catches clicks (its CTA buttons); the rest
		// are inert so they never intercept a click mid-scroll.
		t.el.style.pointerEvents = vis > 0.99 ? 'auto' : 'none';
		t.el.style.transform =
			'translate(-50%,-50%) translate3d(' + tx.toFixed( 1 ) + 'px,' + ty.toFixed( 1 ) + 'px,0)';
		if ( t.anim === 'reveal' ) {
			const c = 'inset(' + ( ( 1 - vis ) * 100 ).toFixed( 1 ) + '% 0 0 0)';
			t.el.style.clipPath = c;
			t.el.style.webkitClipPath = c;
			t.hadClip = true;
		} else if ( t.hadClip ) {
			t.el.style.clipPath = '';
			t.el.style.webkitClipPath = '';
			t.hadClip = false;
		}
	}
}

function initHero( root ) {
	if ( root.classList.contains( 'gene-hero--editor' ) ) return;

	const beats = Array.prototype.slice.call( root.querySelectorAll( '.gene-hero__beat' ) );
	const scrub = root.getAttribute( 'data-scrub' ) === '1';
	// Free-placement timeline (Pro): headings are placed at (x%,y%) over the
	// pinned hero and each lives in its own scroll window with an enter/hold/exit
	// preset. It replaces the one-at-a-time story swap when engaged.
	const timeline = root.getAttribute( 'data-timeline' ) === '1' && beats.length > 0;
	const story = ! timeline && beats.length > 1;
	// Nothing to drive → leave the plain hero (static heading over the bg).
	if ( ! scrub && ! story && ! timeline ) return;
	if ( prefersReducedMotion() || pinBreakKind( root ) !== 'none' ) return;

	// A precise pointer means a desktop-class device, where seeking a paused
	// video renders fine. Touch devices (notably iOS) will not paint a seeked
	// frame of a video that has never played — see the priming below.
	const fine = ! window.matchMedia || window.matchMedia( '(pointer: fine)' ).matches;

	root.classList.add( 'gene-hero--live' );
	if ( timeline ) root.classList.add( 'gene-hero--timeline' );
	else if ( story ) root.classList.add( 'gene-hero--story' );

	// Positions depend on the viewport (mobile can override x/y), so beats are
	// (re)placed on init + resize.
	const sharedAnim = root.getAttribute( 'data-beat-anim' ) || 'rise';
	const tbeats = timeline ? readTimelineBeats( root, sharedAnim ) : null;
	function placeBeats() {
		if ( timeline ) placeTimelineBeats( tbeats );
	}
	function driveTimeline( sp ) {
		driveTimelineBeats( tbeats, sp );
	}
	placeBeats();

	// A live hero builds a tall scroll runway ( screens × 100vh ). The browser's
	// default scroll restoration would drop the visitor deep inside it on reload
	// (and, under a theme's scroll-behavior:smooth, visibly scroll there) — so
	// opt this page into loading at the top. Hash-anchor jumps are unaffected.
	if ( 'scrollRestoration' in history ) {
		try {
			history.scrollRestoration = 'manual';
		} catch ( e ) {}
	}

	// End-of-scroll break runs over the last `zone` fraction of the runway;
	// scrub + beat swaps complete within the remaining [0, END].
	const shatter = scrub && root.getAttribute( 'data-shatter' ) === '1';
	// 'split' (Pro) tears the frame into vertical slats; default is glass shards.
	const splitStyle = shatter && root.getAttribute( 'data-shatter-style' ) === 'split';
	let zone = 0;
	if ( shatter ) {
		const raw = parseFloat( getComputedStyle( root ).getPropertyValue( '--shatter-zone' ) );
		zone = raw > 0 ? clamp( raw, 0.08, 0.4 ) : 0.2;
	}
	const END = 1 - zone;

	// `scrubReady` gates the currentTime seek. Desktop: pause now and scrub.
	// Touch (iOS): a never-played video seeks blank, so "prime" it — let the
	// muted autoplay run to warm the decoder, then take over. If autoplay is
	// blocked (e.g. Low Power Mode) we never prime and leave the poster/looping
	// video rather than blanking the hero.
	const video = scrub ? root.querySelector( '.gene-hero__bg video' ) : null;
	let scrubReady = false;
	if ( video ) {
		video.preload = 'auto';
		// Pro: a separate phone clip. Swap it in once, up front, before we prime or
		// scrub — so only the clip that matches the viewport ever loads. No JS →
		// the desktop clip shows everywhere (never-break).
		const mob = video.parentElement && video.parentElement.getAttribute( 'data-bg-mobile' );
		if ( mob && window.matchMedia && window.matchMedia( '(max-width: 781px)' ).matches ) {
			try { video.src = mob; } catch ( e ) {}
		}
		if ( fine ) {
			video.autoplay = false;
			video.loop = false;
			try { video.pause(); } catch ( e ) {}
			scrubReady = true;
		} else {
			const prime = function () {
				if ( scrubReady ) return;
				scrubReady = true;
				video.loop = false;
				try { video.pause(); } catch ( e ) {}
				queue();
			};
			video.addEventListener( 'playing', function () { setTimeout( prime, 200 ); }, { once: true } );
			const played = video.play && video.play();
			if ( played && played.catch ) { played.catch( function () {} ); }
		}
	}

	// --- Shatter overlay (built lazily from the frozen last frame) -----------
	const bgEl = shatter ? root.querySelector( '.gene-hero__bg' ) : null;
	let shards = null;
	let layer = null;
	let shatterBuilt = false;
	let shatterOn = false;

	function buildShatter() {
		if ( shatterBuilt || ! bgEl || ! video ) return;
		const cap = captureShatter( video, splitStyle );
		if ( ! cap ) return; // tainted / undecoded → no shatter, the video just holds
		shards = cap.shards;
		layer = cap.layer;
		bgEl.appendChild( layer );
		shatterBuilt = true;
	}

	function showShatter( on ) {
		if ( ! shatterBuilt || on === shatterOn ) return;
		shatterOn = on;
		bgEl.classList.toggle( 'is-shattering', on );
	}

	function applyEffect( f ) {
		if ( ! shatterBuilt ) return;
		( splitStyle ? applySplitTo : applyShatterTo )( shards, f );
	}

	const n = beats.length;
	let dur = 0;
	let target = 0;
	let current = 0;
	let active = -1;
	let raf = 0;

	function vh() {
		return window.innerHeight || document.documentElement.clientHeight;
	}
	function progress() {
		const r = root.getBoundingClientRect();
		const scrollable = root.offsetHeight - vh();
		const p = scrollable > 0 ? -r.top / scrollable : 0;
		return clamp( p, 0, 1 );
	}
	function tick() {
		raf = 0;
		const p = progress();
		// scrub + beat swaps complete within [0, END]; the tail drives the shatter.
		const sp = END < 1 ? clamp( p / END, 0, 1 ) : p;

		if ( timeline ) {
			driveTimeline( sp );
		} else if ( story ) {
			const idx = Math.min( n - 1, Math.floor( sp * n ) );
			if ( idx !== active ) {
				active = idx;
				for ( let i = 0; i < n; i++ ) {
					beats[ i ].classList.remove( 'is-on', 'is-past' );
					if ( i < active ) beats[ i ].classList.add( 'is-past' );
					else if ( i === active ) beats[ i ].classList.add( 'is-on' );
				}
			}
		}

		if ( video && scrubReady && dur ) {
			target = sp * ( dur - 0.05 );
			current = lerp( current, target, SCRUB_EASE );
			if ( Math.abs( target - current ) < 0.008 ) current = target;
			if ( ! video.seeking ) {
				try { video.currentTime = current; } catch ( e ) {}
			}
			if ( Math.abs( target - current ) > 0.004 ) queue();
		}

		if ( shatter && scrubReady ) {
			if ( p > END ) {
				if ( ! shatterBuilt ) buildShatter();
				if ( shatterBuilt ) {
					showShatter( true );
					applyEffect( ( p - END ) / zone );
				}
			} else if ( shatterBuilt && shatterOn ) {
				// Left the break zone (scrolled back up): snap the pieces back to
				// whole and hide the overlay so the live video shows again.
				// Without this a fast scroll-back freezes the pieces mid-break.
				applyEffect( 0 );
				showShatter( false );
			}
		}
	}
	function queue() {
		if ( ! raf ) raf = requestAnimationFrame( tick );
	}

	if ( video ) {
		if ( video.readyState >= 1 && video.duration ) {
			dur = video.duration;
		} else {
			video.addEventListener( 'loadedmetadata', () => { dur = video.duration || 0; queue(); }, { once: true } );
		}
	}

	window.addEventListener( 'scroll', queue, { passive: true } );
	window.addEventListener( 'resize', function () {
		placeBeats();
		queue();
	} );
	queue();
}

// --- Multi-scene (chapters) ------------------------------------------------
// A sequence of background videos: as you scroll, chapter 1's video scrubs to
// its end, then its last frame breaks apart (glass shards / split slats) to
// reveal chapter 2 beneath, which scrubs, and so on — reversible on scroll-back.
// Each scene is stacked in the pin; only the current (and, briefly, the next)
// scene is loaded and shown, so weight stays flat no matter how many chapters.
//   - reduced motion / broken pin → the first chapter stays a plain looping
//     hero (never-break). Later chapters never even download.
//   - touch (iOS won't paint a seeked frame of a never-played video) → each
//     chapter's decoder is "primed" with a brief muted play before we scrub it
//     (the same priming the single hero uses). If autoplay is blocked (e.g.
//     Low Power Mode) we never engage and the plain hero stays.
//   - if a frame can't be captured (tainted / undecoded) → that hand-off falls
//     back to a plain crossfade rather than a broken break.
function initMulti( root ) {
	if ( root.classList.contains( 'gene-hero--editor' ) ) return;
	const sceneEls = Array.prototype.slice.call( root.querySelectorAll( '.gene-hero__scene' ) );
	const N = sceneEls.length;
	if ( N < 1 ) return;

	// Not engaging the sequence (reduced motion / broken pin): leave chapter 1
	// as a plain looping hero — and on small screens swap in its lighter mobile
	// clip so phones load the right-sized video (never-break, weight).
	function bail() {
		const s0 = sceneEls[ 0 ];
		const v0 = s0 && s0.querySelector( '.gene-hero__bg video' );
		const mob0 = s0 && s0.getAttribute( 'data-bg-mobile' );
		if ( v0 && mob0 && window.matchMedia && window.matchMedia( '(max-width: 781px)' ).matches ) {
			try { v0.src = mob0; } catch ( e ) {}
		}
	}
	if ( prefersReducedMotion() || pinBreakKind( root ) !== 'none' ) { bail(); return; }
	// A precise pointer means a desktop-class device, where seeking a paused
	// video renders fine. Touch devices engage too, but only after chapter 1's
	// decoder is warm (primed) — see below.
	const fine = ! window.matchMedia || window.matchMedia( '(pointer: fine)' ).matches;
	const small = window.matchMedia && window.matchMedia( '(max-width: 781px)' ).matches;

	function engage() {

	if ( 'scrollRestoration' in history ) {
		try { history.scrollRestoration = 'manual'; } catch ( e ) {}
	}
	root.classList.add( 'gene-hero--live', 'gene-hero--multi-live' );

	// How chapters hand off: 'split' (Pro) tears the frame into vertical slats,
	// otherwise glass shards. Set aside for when a capture fails → crossfade.
	const splitStyle = root.getAttribute( 'data-trans-style' ) === 'split';
	// Free placement (Pro): each chapter's headings are pinned at (x%,y%) and
	// driven through their own scroll windows over that chapter's hold — the
	// single hero's timeline, applied per chapter. Replaces the story swap.
	const timeline = root.getAttribute( 'data-timeline' ) === '1';
	const sharedAnim = root.getAttribute( 'data-beat-anim' ) || 'rise';
	if ( timeline ) root.classList.add( 'gene-hero--timeline' );
	const scenes = sceneEls.map( function ( el, i ) {
		return {
			el: el,
			video: el.querySelector( '.gene-hero__bg video' ),
			bg: el.querySelector( '.gene-hero__bg' ),
			beats: el.querySelector( '.gene-hero__beats' ),
			// A chapter can carry more than one heading; they swap one at a time
			// over the chapter's hold (like the single hero's story mode) — or,
			// with free placement on, each is driven through its own window.
			beatEls: Array.prototype.slice.call( el.querySelectorAll( '.gene-hero__beat' ) ),
			tbeats: timeline ? readTimelineBeats( el, sharedAnim ) : null,
			bActive: -1,
			i: i, dur: 0, cur: 0, ready: false, loaded: false, primed: false,
			shBuilt: false, shOn: false, shards: null,
		};
	} );
	function placeSceneBeats() {
		if ( ! timeline ) return;
		for ( let i = 0; i < scenes.length; i++ ) {
			if ( scenes[ i ].tbeats ) placeTimelineBeats( scenes[ i ].tbeats );
		}
	}
	placeSceneBeats();

	function swapBeats( s, bi ) {
		if ( s.beatEls.length < 2 || bi === s.bActive ) return;
		s.bActive = bi;
		for ( let k = 0; k < s.beatEls.length; k++ ) {
			s.beatEls[ k ].classList.remove( 'is-on', 'is-past' );
			if ( k < bi ) s.beatEls[ k ].classList.add( 'is-past' );
			else if ( k === bi ) s.beatEls[ k ].classList.add( 'is-on' );
		}
	}

	let raf = 0;
	function queue() { if ( ! raf ) raf = requestAnimationFrame( tick ); }

	// The breaking chapter's frame → shards, built lazily on its first hand-off.
	// An unprimed video would capture a blank frame — hold off (crossfade shows
	// meanwhile) and build once it's warm.
	function buildSceneShatter( s ) {
		if ( s.shBuilt || ! s.video || ! s.bg || ! s.primed ) return;
		const cap = captureShatter( s.video, splitStyle );
		if ( ! cap ) return; // couldn't read the frame → we fall back to a crossfade
		s.shards = cap.shards;
		s.bg.appendChild( cap.layer );
		s.shBuilt = true;
	}
	function showSceneShatter( s, on ) {
		if ( ! s.shBuilt || s.shOn === on ) return;
		s.shOn = on;
		s.bg.classList.toggle( 'is-shattering', on );
	}
	function applySceneEffect( s, f ) {
		if ( ! s.shBuilt ) return;
		( splitStyle ? applySplitTo : applyShatterTo )( s.shards, f );
	}

	// Lazy load: assign a scene's video src on approach (later scenes ship with
	// their URL held in data-src, so nothing downloads until we reach them). The
	// first scene ships eager (its src is the no-JS fallback) — here we just take
	// it over: stop the autoplay loop so it can scrub.
	function ensureLoaded( i ) {
		const s = scenes[ i ];
		if ( ! s || ! s.video || s.loaded ) return;
		s.loaded = true;
		const v = s.video;
		v.autoplay = false;
		v.loop = false;
		v.preload = 'auto';
		const dataSrc = v.getAttribute( 'data-src' );
		const mob = s.el.getAttribute( 'data-bg-mobile' );
		const src = small && mob ? mob : ( v.getAttribute( 'src' ) || dataSrc || '' );
		if ( src && v.getAttribute( 'src' ) !== src ) {
			try { v.src = src; } catch ( e ) {}
		}
		if ( fine ) {
			try { v.pause(); } catch ( e ) {}
			s.primed = true;
		} else {
			// Touch: a never-played video seeks blank (iOS) — warm this chapter's
			// decoder with a brief muted play, then take over. Chapters load one
			// ahead (active+1), so priming finishes while they're still hidden.
			// Seeks are held off (s.primed) until then.
			const prime = function () {
				if ( s.primed ) return;
				s.primed = true;
				v.loop = false;
				try { v.pause(); } catch ( e ) {}
				queue();
			};
			v.addEventListener( 'playing', function () { setTimeout( prime, 200 ); }, { once: true } );
			if ( ! v.paused && v.readyState >= 2 ) {
				prime(); // already rendering frames (chapter 1's autoplay) — warm now
			} else {
				v.muted = true;
				const played = v.play && v.play();
				if ( played && played.catch ) { played.catch( function () {} ); }
			}
		}
		if ( v.readyState >= 1 && v.duration ) { s.dur = v.duration; s.ready = true; }
		else v.addEventListener( 'loadedmetadata', function () { s.dur = v.duration || 0; s.ready = !! s.dur; queue(); }, { once: true } );
	}
	ensureLoaded( 0 );

	const TRANS = 0.16; // last fraction of a chapter spent breaking apart to the next
	function vh() { return window.innerHeight || document.documentElement.clientHeight; }
	function progress() {
		const r = root.getBoundingClientRect();
		const scrollable = root.offsetHeight - vh();
		return clamp( scrollable > 0 ? -r.top / scrollable : 0, 0, 1 );
	}
	function seek( s, frac ) {
		if ( ! s || ! s.video || ! s.ready || ! s.dur || ! s.primed ) return;
		const targ = clamp( frac, 0, 1 ) * ( s.dur - 0.05 );
		s.cur = lerp( s.cur, targ, SCRUB_EASE );
		if ( Math.abs( targ - s.cur ) < 0.008 ) s.cur = targ;
		if ( ! s.video.seeking ) { try { s.video.currentTime = s.cur; } catch ( e ) {} }
		if ( Math.abs( targ - s.cur ) > 0.004 ) queue();
	}
	function tick() {
		raf = 0;
		const p = progress();
		const fp = p * N;
		const active = clamp( Math.floor( fp ), 0, N - 1 );
		const local = clamp( fp - active, 0, 1 );
		const inTrans = active < N - 1 && local > 1 - TRANS;
		const tf = inTrans ? ( local - ( 1 - TRANS ) ) / TRANS : 0;

		ensureLoaded( active );
		if ( active + 1 < N ) ensureLoaded( active + 1 );

		for ( let i = 0; i < N; i++ ) {
			const s = scenes[ i ];
			let op = 0, z = 0;
			const breaking = i === active && inTrans;
			const next = i === active + 1 && inTrans;

			if ( breaking ) {
				// The outgoing chapter sits on top (z 3) and breaks apart; the next
				// chapter is revealed through the gaps. If the frame couldn't be
				// captured, cross-fade this chapter out instead.
				z = 3;
				buildSceneShatter( s );
				if ( s.shBuilt ) {
					op = 1;
					showSceneShatter( s, true );
					applySceneEffect( s, tf );
					if ( s.beats ) s.beats.style.opacity = ( 1 - tf ).toFixed( 3 );
				} else {
					op = 1 - tf;
					if ( s.beats ) s.beats.style.opacity = '';
				}
			} else if ( next ) {
				op = 1; // full frame, sitting beneath the breaking chapter (z 2)
				z = 2;
				if ( s.beats ) s.beats.style.opacity = '';
			} else {
				if ( i === active ) { op = 1; z = 1; }
				// Not participating this frame: reassemble + hide any stale shatter
				// so a scroll-back re-breaks cleanly (and never leaves frozen shards).
				if ( s.shOn ) { applySceneEffect( s, 0 ); showSceneShatter( s, false ); }
				if ( s.beats ) s.beats.style.opacity = '';
			}
			// Headings over the chapter's hold (before the break). The active
			// chapter tracks the scroll; the next one waits at its start, past
			// ones rest at their end — reversible like everything else.
			const bp = i === active ? clamp( local / ( 1 - TRANS ), 0, 1 ) : i < active ? 1 : 0;
			if ( timeline && s.tbeats && s.tbeats.length ) {
				// Free placement: drive each placed heading through its window.
				driveTimelineBeats( s.tbeats, bp );
			} else if ( s.beatEls.length > 1 ) {
				// Story mode: swap them one at a time.
				swapBeats( s, Math.min( s.beatEls.length - 1, Math.floor( bp * s.beatEls.length ) ) );
			}
			s.el.style.opacity = op.toFixed( 3 );
			s.el.style.zIndex = String( z );
			// Only the active (settled) chapter catches clicks, so its CTA buttons
			// work while off-screen / breaking chapters stay inert.
			s.el.style.pointerEvents = i === active && ! inTrans ? 'auto' : 'none';
		}

		// The breaking chapter holds on its last frame while it shatters; the next
		// chapter sits on its first frame ready to be revealed.
		seek( scenes[ active ], local );
		if ( active + 1 < N ) seek( scenes[ active + 1 ], 0 );
	}

	window.addEventListener( 'scroll', queue, { passive: true } );
	window.addEventListener( 'resize', function () {
		placeSceneBeats(); // phone x/y overrides follow the viewport
		queue();
	} );
	queue();

	} // engage()

	if ( fine ) {
		engage();
		return;
	}
	// Touch: engage only once chapter 1 actually plays (proof the decoder can
	// warm) — if autoplay is blocked (e.g. Low Power Mode) the plain looping
	// hero simply stays (never-break, same as no JS).
	const s0 = sceneEls[ 0 ];
	const v0 = s0 && s0.querySelector( '.gene-hero__bg video' );
	if ( ! v0 ) {
		engage(); // image first chapter: nothing to prime
		return;
	}
	// Swap in the phone clip before priming so only the right file ever loads.
	const mob0 = s0.getAttribute( 'data-bg-mobile' );
	if ( mob0 && small && v0.getAttribute( 'src' ) !== mob0 ) {
		try { v0.src = mob0; } catch ( e ) {}
	}
	let engaged = false;
	const go = function () {
		if ( engaged ) return;
		engaged = true;
		engage();
	};
	v0.addEventListener( 'playing', function () { setTimeout( go, 200 ); }, { once: true } );
	if ( ! v0.paused && v0.readyState >= 2 ) {
		setTimeout( go, 200 ); // already playing before we attached the listener
	} else {
		v0.muted = true;
		const played = v0.play && v0.play();
		if ( played && played.catch ) { played.catch( function () {} ); }
	}
}

function boot() {
	const els = document.querySelectorAll( '.gene-hero:not(.gene-hero--editor)' );
	for ( let i = 0; i < els.length; i++ ) {
		if ( els[ i ].getAttribute( 'data-multi' ) === '1' ) initMulti( els[ i ] );
		else initHero( els[ i ] );
	}
}

if ( document.readyState === 'loading' ) {
	document.addEventListener( 'DOMContentLoaded', boot );
} else {
	boot();
}
