// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// animations-v3.jsx — continuous-composition animation engine.
//
// THE MODEL: the animation is ONE element tree rendered as a pure function
// of one authored-time axis. Nothing mounts or unmounts at section
// boundaries, so any element can move, morph, or persist across them by
// ordinary interpolation. The scene list (OM_SCENES) is the user-control
// view — names, order, playback durations — and the engine derives the cue
// table from it, so structure has exactly one source and cannot drift.
//
// API INDEX (every export is a window global):
//
// — ONE component, the whole animation
//
// useComposition() -> {T, CUES, time, duration, authoredTotal, playing}
// T: authored seconds (warped per-section by user trims/speeds) —
// key ALL choreography to T, never to wall-clock time
// CUES: {SectionName: authoredStart} derived from OM_SCENES; an unknown
// name returns NaN and raises a preview-only badge (never exports);
// duplicate section names bind to the first occurrence
// — children visible between two
// authored times (an authored hard cut in one line); children stay
// mounted (media keeps its readiness) and are hidden outside the window
// — ONE caption element,
// at most one visible at a time, keyed to T; 'until' defaults to the
// next item's 'at'; a last item with no 'until' stays to the end
// WATERCOLOR (only when the Watercolor illustration skill is active —
// otherwise ignore these entries). A painting is a function(p) written
// against the paint kit, on a width x height sheet; it needs
// watercolor_kit.js loaded by a
// Give every entry a "desc": one short plain-words sentence saying
// what happens in that section. The user reads it in the timeline's
// section popover — keep it true whenever you edit the section.
// 2. Pass the string through untouched:
//
// 3. ALSO declare the playback setting the same way:
//
// and pass it through untouched (values: '{"mode":"loop"}' or
// '{"mode":"times","count":N}'; omitting keeps loop behavior but
// leaves the host Repeat control read-only for this document).
// IMPORTANT — the exportable-video contract: CompositionStage/Stage OWNS
// it (the data-om-exportable-video-with-duration-secs attribute, the
// data-om-seek-to-time-frame listener, the svg/foreignObject wrapper,
// and font inlining). NEVER put the exportable attribute on any other
// element — a second "exportable root" makes the host timeline and the
// video exporter bind to the wrong element, and playback control /
// export silently break.
//
// HOW TIME WORKS: each OM_SCENES entry is a named slice of the authored
// timeline. CUES.Name is that section's authored start (the running sum of
// authored lengths, in literal order). useComposition().T is the authored
// clock: when the user trims or speeds a section on the host timeline, the
// engine replays that section's SAME authored slice over the new playback
// length — your choreography retimes, never cuts off. The optional "nat"
// field on an entry is the engine's authored-length anchor — the host
// timeline stamps it on the first retime; don't set it by hand.
//
// CUE-FIRST DISCIPLINE (what makes a piece read as one continuous video):
// 1. Write the OM_SCENES literal FIRST — it is the piece's outline.
// 2. One helper component per section for readability, but ALL of them
// render ALL the time inside the one tree, keyed to CUES — never
// conditionally mounted per section.
// 3. Define exactly three motion helpers up front (e.g.
// MOTION = {enter, draw, pop} wrapping Easing curves) and use no
// easing or transform outside them; one caption element, one visible
// at a time ( has this built in).
// A shared element that crosses a boundary is just motion whose start
// and end straddle a cue: animate({from, to, start: CUES.Build - 0.4,
// end: CUES.Build + 0.6})(T) glides through the boundary, and a user
// slowing either section slows the glide without breaking it.
//
// RENDER FROM T ONLY: the exporter seeks each frame with a synchronous
// commit and may serialize the stage the moment the seek event returns —
// anything painted from useEffect or your own requestAnimationFrame lags
// that commit and exports stale. Render everything visible from T and this
// is automatic. A seeked frame is a deterministic render at that time.
//
// HARD CUTS are content now, not structure: wrap a shot's elements in
// (visibility toggles at the cues; children stay mounted so
// images and videos hold their readiness). Shot also doubles as the
// perf gate for heavy far-away beats.
//
// LOOP SEAMS are the one surviving boundary rule: a looping piece shows
// its last authored frame immediately before its first — make them match
// (settle your choreography by authoredTotal, open it at 0).
//
// DIAGNOSTICS: choreography that references an unknown section name (a
// rename or deletion in OM_SCENES) shows a badge below the stage in the
// preview, outside the exportable svg — visible in preview screenshots,
// never in the exported video. An OM_SCENES section with no choreography
// keyed to it is a valid empty beat, not an error.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
const Easing = {
linear: (t) => t,
// Quad
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// Cubic
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => (--t) * t * t + 1,
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
// Quart
easeInQuart: (t) => t * t * t * t,
easeOutQuart: (t) => 1 - (--t) * t * t * t,
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
// Expo
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
},
// Sine
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
// Back (overshoot)
easeOutBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeInBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return c3 * t * t * t - c1 * t * t;
},
easeInOutBack: (t) => {
const c1 = 1.70158, c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
// Elastic
easeOutElastic: (t) => {
const c4 = (2 * Math.PI) / 3;
if (t === 0) return 0;
if (t === 1) return 1;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// ── Core interpolation helpers ──────────────────────────────────────────────
// Clamp a value to [min, max]
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
// Popmotion-style: linearly maps t across input keyframes to output values,
// with optional easing per segment (single fn or array of fns).
function interpolate(input, output, ease = Easing.linear) {
return (t) => {
if (t <= input[0]) return output[0];
if (t >= input[input.length - 1]) return output[output.length - 1];
for (let i = 0; i < input.length - 1; i++) {
if (t >= input[i] && t <= input[i + 1]) {
const span = input[i + 1] - input[i];
const local = span === 0 ? 0 : (t - input[i]) / span;
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
const eased = easeFn(local);
return output[i] + (output[i + 1] - output[i]) * eased;
}
}
return output[output.length - 1];
};
}
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
// Returns `from` before `start`, `to` after `end`.
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
return (t) => {
if (t <= start) return from;
if (t >= end) return to;
const local = (t - start) / (end - start);
return from + (to - from) * ease(local);
};
}
// ── Timeline context ────────────────────────────────────────────────────────
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
const useTime = () => React.useContext(TimelineContext).time;
const useTimeline = () => React.useContext(TimelineContext);
// How long a marked (detail.playing === true) host seek keeps the
// external-playback latch alive with no successor. The host play bar's
// seek pump is one-in-flight/latest-wins, so its inter-seek gap is tens
// of milliseconds in the worst case — 400ms is far above that, so a
// marked stream that dies mid-play decays the latch promptly.
var SS_EXT_PLAY_MS = 400;
// ── Font inlining ───────────────────────────────────────────────────────────
// Copy every @font-face rule from the page into a