// @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