// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)

/* BEGIN USAGE */
// animations.jsx — timeline engine. Exports (on window): Stage, Sprite,
//   TextSprite, ImageSprite, RectSprite, VideoSprite, PlaybackBar,
//   useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
//
//   <Stage width={1280} height={720} duration={10} background="#f6f4ef">
//     <Sprite start={0} end={3}>
//       <TextSprite text="Hello" x={100} y={300} size={72} color="#111" />
//     </Sprite>
//     <Sprite start={2} end={8}>
//       <ImageSprite src="hero.png" x={200} y={120} width={640} height={360} kenBurns />
//     </Sprite>
//   </Stage>
//
// Stage({width,height,duration,background,fps,loop,autoplay}) — auto-scales to
//   viewport; scrubber + play/pause + ←/→ seek + space + 0-reset; persists
//   playhead. The canvas is an <svg><foreignObject>, export-ready: Share →
//   Export → Video (or the PlaybackBar's download button) renders it to .mp4.
//   Screenshot tools DOM-rerender (not pixel-capture) and unwrap this wrapper
//   so captures should work — but if one comes back black, that's a capture
//   artifact, not a render bug; trust the live preview.
// Sprite({start,end,keepMounted}) — mounts children only while playhead is in
//   [start,end]. Children read {localTime, progress, duration} via useSprite().
// useTime() → seconds; useTimeline() → {time,duration,playing,setTime,setPlaying}.
// TextSprite({text,x,y,size,color,font,weight,align,entryDur,exitDur}) — fades/scales in+out.
// ImageSprite({src,x,y,width,height,fit,radius,kenBurns,placeholder}) — same, with optional ken-burns.
// RectSprite({x,y,width,height,color,radius}) — solid box with entry/exit.
// VideoSprite({src,start,end,speed,style}) — looped <video> clip synced to the
//   timeline; its audio is mixed into the exported video.
// Easing.{linear,easeIn/Out/InOut Quad/Cubic/Quart/Quint/Expo/Back, …}
// interpolate([t0,t1,…],[v0,v1,…],ease?) → (t)=>v  — piecewise tween.
// animate({from,to,start,end,ease}) → (t)=>v  — single tween.
//
// Build scenes by composing Sprites inside Stage. Absolutely-position elements.
//
// In a .dc.html project, put your scene in a sibling my-scene.jsx (reading
// {Stage, Sprite, useTime, Easing, …} from window is safe) and mount BOTH:
//   <x-import component-from-global-scope="MyScene"
//             from="./animations.jsx ./my-scene.jsx"></x-import>
// The two files in from= load in order, so my-scene.jsx can use the globals
// animations.jsx set.
/* 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);

// ── Sprite ──────────────────────────────────────────────────────────────────
// Renders children only when the playhead is inside [start, end]. Provides
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
//
//   <Sprite start={2} end={5}>
//     {({ localTime, progress }) => <Thing x={progress * 100} />}
//   </Sprite>
//
// Or as a plain wrapper — children can call useSprite() themselves.

const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
const useSprite = () => React.useContext(SpriteContext);

function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
  const { time } = useTimeline();
  const visible = time >= start && time <= end;
  if (!visible && !keepMounted) return null;

  const duration = end - start;
  const localTime = Math.max(0, time - start);
  const progress = duration > 0 && isFinite(duration)
    ? clamp(localTime / duration, 0, 1)
    : 0;

  const value = { localTime, progress, duration, visible };

  return (
    <SpriteContext.Provider value={value}>
      {typeof children === 'function' ? children(value) : children}
    </SpriteContext.Provider>
  );
}

// ── Sample sprite components ────────────────────────────────────────────────

// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
// Props: text, x, y, size, color, font, entryDur, exitDur, align
function TextSprite({
  text,
  x = 0, y = 0,
  size = 48,
  color = '#111',
  font = 'Inter, system-ui, sans-serif',
  weight = 600,
  entryDur = 0.45,
  exitDur = 0.35,
  entryEase = Easing.easeOutBack,
  exitEase = Easing.easeInCubic,
  align = 'left',
  letterSpacing = '-0.01em',
}) {
  const { localTime, duration } = useSprite();
  const exitStart = Math.max(0, duration - exitDur);

  let opacity = 1;
  let ty = 0;

  if (localTime < entryDur) {
    const t = entryEase(clamp(localTime / entryDur, 0, 1));
    opacity = t;
    ty = (1 - t) * 16;
  } else if (localTime > exitStart) {
    const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
    opacity = 1 - t;
    ty = -t * 8;
  }

  const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';

  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      transform: `translate(${translateX}, ${ty}px)`,
      opacity,
      fontFamily: font,
      fontSize: size,
      fontWeight: weight,
      color,
      letterSpacing,
      whiteSpace: 'pre',
      lineHeight: 1.1,
      willChange: 'transform, opacity',
    }}>
      {text}
    </div>
  );
}

// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
function ImageSprite({
  src,
  x = 0, y = 0,
  width = 400, height = 300,
  entryDur = 0.6,
  exitDur = 0.4,
  kenBurns = false,
  kenBurnsScale = 1.08,
  radius = 12,
  fit = 'cover',
  placeholder = null, // {label: string} for striped placeholder
}) {
  const { localTime, duration } = useSprite();
  const exitStart = Math.max(0, duration - exitDur);

  let opacity = 1;
  let scale = 1;

  if (localTime < entryDur) {
    const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
    opacity = t;
    scale = 0.96 + 0.04 * t;
  } else if (localTime > exitStart) {
    const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
    opacity = 1 - t;
    scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
  } else if (kenBurns) {
    const holdSpan = exitStart - entryDur;
    const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
    scale = 1 + (kenBurnsScale - 1) * holdT;
  }

  const content = placeholder ? (
    <div style={{
      width: '100%', height: '100%',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
      color: '#6b6458',
      fontFamily: 'JetBrains Mono, ui-monospace, monospace',
      fontSize: 13,
      letterSpacing: '0.04em',
      textTransform: 'uppercase',
    }}>
      {placeholder.label || 'image'}
    </div>
  ) : (
    <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
  );

  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      width, height,
      opacity,
      transform: `scale(${scale})`,
      transformOrigin: 'center',
      borderRadius: radius,
      overflow: 'hidden',
      willChange: 'transform, opacity',
    }}>
      {content}
    </div>
  );
}

// RectSprite: simple rectangle that animates position/size/color via props.
// Useful demo primitive — takes a `render` fn for per-frame customization.
function RectSprite({
  x = 0, y = 0,
  width = 100, height = 100,
  color = '#111',
  radius = 8,
  entryDur = 0.4,
  exitDur = 0.3,
  render, // optional: (ctx) => style overrides
}) {
  const spriteCtx = useSprite();
  const { localTime, duration } = spriteCtx;
  const exitStart = Math.max(0, duration - exitDur);

  let opacity = 1;
  let scale = 1;

  if (localTime < entryDur) {
    const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
    opacity = clamp(localTime / entryDur, 0, 1);
    scale = 0.4 + 0.6 * t;
  } else if (localTime > exitStart) {
    const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
    opacity = 1 - t;
    scale = 1 - 0.15 * t;
  }

  const overrides = render ? render(spriteCtx) : {};

  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      width, height,
      background: color,
      borderRadius: radius,
      opacity,
      transform: `scale(${scale})`,
      transformOrigin: 'center',
      willChange: 'transform, opacity',
      ...overrides,
    }} />
  );
}


// ── Font inlining ───────────────────────────────────────────────────────────
// Copy every @font-face rule from the page into a <style> inside the svg's
// foreignObject, with font URLs rewritten to data: URLs. Makes the svg
// self-describing so serializing it alone (video export fast path) still
// renders with the right fonts. Sets data-om-fonts-inlined on the svg when
// done so the exporter can wait for it.

function useInlineFontsInto(svgRef) {
  React.useEffect(() => {
    const svg = svgRef.current;
    const host = svg && svg.querySelector('foreignObject > div');
    if (!svg || !host) return;
    let cancelled = false;
    (async () => {
      const rules = [];
      for (const ss of document.styleSheets) {
        let cssRules;
        try { cssRules = ss.cssRules; } catch {
          // Cross-origin sheet without crossorigin attr (e.g. the standard
          // fonts.googleapis.com <link>) — fetch the CSS text directly and
          // regex-extract the @font-face blocks.
          if (ss.href) {
            try {
              const txt = await fetch(ss.href).then(r => { if (!r.ok) throw 0; return r.text(); });
              for (const ff of (txt.match(/@font-face\s*{[^}]*}/g) || []))
                rules.push({ css: ff, base: ss.href });
            } catch {}
          }
          continue;
        }
        if (!cssRules) continue;
        for (const r of cssRules) {
          if (r.type === CSSRule.FONT_FACE_RULE) {
            rules.push({ css: r.cssText, base: ss.href || location.href });
          }
        }
      }
      const toDataURL = (url) => fetch(url)
        .then(r => { if (!r.ok) throw 0; return r.blob(); })
        .then(b => new Promise(res => {
          const fr = new FileReader();
          fr.onload = () => res(fr.result);
          fr.onerror = () => res(url);
          fr.readAsDataURL(b);
        }))
        .catch(() => url);
      const parts = await Promise.all(rules.map(async ({ css, base }) => {
        const re = /url\((['"]?)([^'")]+)\1\)/g;
        let out = css, m;
        while ((m = re.exec(css))) {
          const u = m[2];
          if (u.startsWith('data:')) continue;
          let abs; try { abs = new URL(u, base).href; } catch { continue; }
          out = out.split(m[0]).join(`url("${await toDataURL(abs)}")`);
        }
        return out;
      }));
      if (cancelled || !parts.length) {
        svg.setAttribute('data-om-fonts-inlined', 'true');
        return;
      }
      const style = document.createElement('style');
      style.textContent = parts.join('\n');
      host.insertBefore(style, host.firstChild);
      svg.setAttribute('data-om-fonts-inlined', 'true');
    })();
    return () => { cancelled = true; };
  }, []);
}


function Stage({
  width = 1280,
  height = 720,
  duration = 10,
  background = '#f6f4ef',
  fps = 60,
  loop = true,
  autoplay = true,
  chrome = true,
  persistKey = 'animstage',
  children,
}) {
  // Props arrive as strings when Stage is mounted via <x-import> (DC
  // projects) — coerce so style={{width}} gets a number React can px-ify.
  width = +width || 1280; height = +height || 720;
  duration = +duration || 10; fps = +fps || 60;
  if (typeof loop === 'string') loop = loop !== 'false';
  if (typeof autoplay === 'string') autoplay = autoplay !== 'false';
  if (typeof chrome === 'string') chrome = chrome !== 'false';

  const [time, setTime] = React.useState(() => {
    try {
      const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
      return isFinite(v) ? clamp(v, 0, duration) : 0;
    } catch { return 0; }
  });
  const [playing, setPlaying] = React.useState(autoplay);
  const [hoverTime, setHoverTime] = React.useState(null);
  const [scale, setScale] = React.useState(1);

  const stageRef = React.useRef(null);
  const canvasRef = React.useRef(null);
  const rafRef = React.useRef(null);
  const lastTsRef = React.useRef(null);

  // Persist playhead
  React.useEffect(() => {
    try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
  }, [time, persistKey]);

  // Auto-scale to fit viewport
  React.useEffect(() => {
    if (!stageRef.current) return;
    const el = stageRef.current;
    const measure = () => {
      const barH = chrome ? 44 : 0; // playback bar height
      const s = Math.min(
        el.clientWidth / width,
        (el.clientHeight - barH) / height
      );
      setScale(Math.max(0.05, s));
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    window.addEventListener('resize', measure);
    return () => {
      ro.disconnect();
      window.removeEventListener('resize', measure);
    };
  }, [width, height]);

  // Animation loop
  React.useEffect(() => {
    if (!playing) {
      lastTsRef.current = null;
      return;
    }
    const step = (ts) => {
      if (lastTsRef.current == null) lastTsRef.current = ts;
      const dt = (ts - lastTsRef.current) / 1000;
      lastTsRef.current = ts;
      setTime((t) => {
        let next = t + dt;
        if (next >= duration) {
          if (loop) next = next % duration;
          else { next = duration; setPlaying(false); }
        }
        return next;
      });
      rafRef.current = requestAnimationFrame(step);
    };
    rafRef.current = requestAnimationFrame(step);
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      lastTsRef.current = null;
    };
  }, [playing, duration, loop]);

  // Keyboard: space = play/pause, ← → = seek
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
      if (e.code === 'Space') {
        e.preventDefault();
        setPlaying(p => !p);
      } else if (e.code === 'ArrowLeft') {
        setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
      } else if (e.code === 'ArrowRight') {
        setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
      } else if (e.key === '0' || e.code === 'Home') {
        setTime(0);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [duration]);

  // Video-export protocol: the exporter dispatches this event per frame;
  // pause + sync the playhead so the capture sees exactly that timestamp.
  React.useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const onSeek = (e) => {
      setPlaying(false);
      setTime(clamp(e.detail.time, 0, duration));
    };
    el.addEventListener('data-om-seek-to-time-frame', onSeek);
    return () => el.removeEventListener('data-om-seek-to-time-frame', onSeek);
  }, [duration]);

  // Inline @font-face rules into the svg's foreignObject so the svg is
  // self-describing — serializing it alone (for video export) then renders
  // with the right fonts. Sets data-om-fonts-inlined once done.
  useInlineFontsInto(canvasRef);

  const displayTime = hoverTime != null ? hoverTime : time;

  const ctxValue = React.useMemo(
    () => ({ time: displayTime, duration, playing, setTime, setPlaying }),
    [displayTime, duration, playing]
  );

  return (
    <div
      ref={stageRef}
      style={{
        position: 'absolute', inset: 0,
        display: 'flex', flexDirection: 'column',
        alignItems: 'center',
        background: chrome ? '#0a0a0a' : background,
        fontFamily: 'Inter, system-ui, sans-serif',
      }}
    >
      {/* Canvas area — vertically centered in remaining space */}
      <div style={{
        flex: 1,
        width: '100%',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        overflow: 'hidden',
        minHeight: 0,
      }}>
        <svg
          ref={canvasRef}
          width={width} height={height}
          data-om-exportable-video-with-duration-secs={duration}
          style={{
            transform: `scale(${scale})`,
            transformOrigin: 'center',
            flexShrink: 0,
            boxShadow: chrome ? '0 20px 60px rgba(0,0,0,0.4)' : 'none',
            display: 'block',
          }}
        >
          <foreignObject x="0" y="0" width="100%" height="100%">
            <div
              xmlns="http://www.w3.org/1999/xhtml"
              style={{
                width, height,
                background,
                position: 'relative',
                overflow: 'hidden',
              }}
            >
              <TimelineContext.Provider value={ctxValue}>
                {children}
              </TimelineContext.Provider>
            </div>
          </foreignObject>
        </svg>
      </div>

      {/* Playback bar — stacked below canvas, never overlapping */}
      {chrome && <PlaybackBar
        time={displayTime}
        actualTime={time}
        duration={duration}
        playing={playing}
        onPlayPause={() => setPlaying(p => !p)}
        onReset={() => { setTime(0); }}
        onSeek={(t) => setTime(t)}
        onHover={(t) => setHoverTime(t)}
      />}
    </div>
  );
}

// ── Playback bar ────────────────────────────────────────────────────────────
// Play/pause, return-to-begin, scrub track, time display.
// Uses fixed-width time fields so layout doesn't thrash.

function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) {
  const trackRef = React.useRef(null);
  const [dragging, setDragging] = React.useState(false);

  const timeFromEvent = React.useCallback((e) => {
    const rect = trackRef.current.getBoundingClientRect();
    const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
    return x * duration;
  }, [duration]);

  const onTrackMove = (e) => {
    if (!trackRef.current) return;
    const t = timeFromEvent(e);
    if (dragging) {
      onSeek(t);
    } else {
      onHover(t);
    }
  };

  const onTrackLeave = () => {
    if (!dragging) onHover(null);
  };

  const onTrackDown = (e) => {
    setDragging(true);
    const t = timeFromEvent(e);
    onSeek(t);
    onHover(null);
  };

  React.useEffect(() => {
    if (!dragging) return;
    const onUp = () => setDragging(false);
    const onMove = (e) => {
      if (!trackRef.current) return;
      const t = timeFromEvent(e);
      onSeek(t);
    };
    window.addEventListener('mouseup', onUp);
    window.addEventListener('mousemove', onMove);
    return () => {
      window.removeEventListener('mouseup', onUp);
      window.removeEventListener('mousemove', onMove);
    };
  }, [dragging, timeFromEvent, onSeek]);

  const pct = duration > 0 ? (time / duration) * 100 : 0;
  const fmt = (t) => {
    const total = Math.max(0, t);
    const m = Math.floor(total / 60);
    const s = Math.floor(total % 60);
    const cs = Math.floor((total * 100) % 100);
    return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
  };

  const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace';

  return (
    <div data-omelette-chrome style={{
      display: 'flex', alignItems: 'center', gap: 12,
      padding: '8px 16px',
      background: 'rgba(20,20,20,0.92)',
      borderTop: '1px solid rgba(255,255,255,0.08)',
      width: '100%',
      maxWidth: 680,
      alignSelf: 'center',

      borderRadius: 8,
      color: '#f6f4ef',
      fontFamily: 'Inter, system-ui, sans-serif',
      userSelect: 'none',
      flexShrink: 0,
    }}>
      <IconButton onClick={onReset} title="Return to start (0)">
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
          <path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
        </svg>
      </IconButton>
      <IconButton onClick={onPlayPause} title="Play/pause (space)">
        {playing ? (
          <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
            <rect x="3" y="2" width="3" height="10" fill="currentColor"/>
            <rect x="8" y="2" width="3" height="10" fill="currentColor"/>
          </svg>
        ) : (
          <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
            <path d="M3 2l9 5-9 5V2z" fill="currentColor"/>
          </svg>
        )}
      </IconButton>

      {/* Current time: fixed width so it doesn't thrash */}
      <div style={{
        fontFamily: mono,
        fontSize: 12,
        fontVariantNumeric: 'tabular-nums',
        width: 64, textAlign: 'right',
        color: '#f6f4ef',
      }}>
        {fmt(time)}
      </div>

      {/* Scrub track */}
      <div
        ref={trackRef}
        onMouseMove={onTrackMove}
        onMouseLeave={onTrackLeave}
        onMouseDown={onTrackDown}
        style={{
          flex: 1,
          height: 22,
          position: 'relative',
          cursor: 'pointer',
          display: 'flex', alignItems: 'center',
        }}
      >
        <div style={{
          position: 'absolute',
          left: 0, right: 0, height: 4,
          background: 'rgba(255,255,255,0.12)',
          borderRadius: 2,
        }}/>
        <div style={{
          position: 'absolute',
          left: 0, width: `${pct}%`, height: 4,
          background: 'oklch(72% 0.12 250)',
          borderRadius: 2,
        }}/>
        <div style={{
          position: 'absolute',
          left: `${pct}%`, top: '50%',
          width: 12, height: 12,
          marginLeft: -6, marginTop: -6,
          background: '#fff',
          borderRadius: 6,
          boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
        }}/>
      </div>

      {/* Duration: fixed width */}
      <div style={{
        fontFamily: mono,
        fontSize: 12,
        fontVariantNumeric: 'tabular-nums',
        width: 64, textAlign: 'left',
        color: 'rgba(246,244,239,0.55)',
      }}>
        {fmt(duration)}
      </div>

      {typeof VideoEncoder !== 'undefined' && (
        <IconButton
          title="Export video"
          onClick={() => window.parent.postMessage({ type: 'omelette:request-video-export' }, '*')}
        >
          <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
            <path d="M7 2v7m0 0L4 6m3 3l3-3M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
        </IconButton>
      )}
    </div>
  );
}

function IconButton({ children, onClick, title }) {
  const [hover, setHover] = React.useState(false);
  return (
    <button
      onClick={onClick}
      title={title}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        width: 28, height: 28,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: hover ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.04)',
        border: '1px solid rgba(255,255,255,0.1)',
        borderRadius: 6,
        color: '#f6f4ef',
        cursor: 'pointer',
        padding: 0,
        transition: 'background 120ms',
      }}
    >
      {children}
    </button>
  );
}


// ── VideoSprite ─────────────────────────────────────────────────────────────
// Renders a <video> that loops within [start,end] of its source at `speed`,
// kept in sync with the Stage's playhead. Carries the
// data-om-exportable-video-play-* attrs so video export can mix its audio.
//
//   <VideoSprite src="clip.mp4" start={2} end={5} speed={1}
//     style={{ width: 640, height: 360 }} />

function VideoSprite({ src, start = 0, end, speed = 1, style, ...rest }) {
  start = +start || 0; speed = +speed || 1;
  if (end != null) end = +end || undefined;
  const t = useTime();
  const ref = React.useRef(null);
  const span = Math.max(0.001, ((end ?? start + 1) - start));
  React.useEffect(() => {
    const v = ref.current;
    if (!v || v.readyState < 1) return;
    const target = start + ((t * speed) % span);
    if (Math.abs(v.currentTime - target) > 0.05) v.currentTime = target;
  }, [t, start, span, speed]);
  return (
    <video
      ref={ref}
      src={src}
      muted playsInline preload="auto"
      data-om-exportable-video-play-start={start}
      data-om-exportable-video-play-end={end ?? start + span}
      data-om-exportable-video-play-speed={speed}
      style={{ display: 'block', objectFit: 'cover', ...style }}
      {...rest}
    />
  );
}


Object.assign(window, {
  Easing, interpolate, animate, clamp,
  TimelineContext, useTime, useTimeline,
  Sprite, SpriteContext, useSprite,
  TextSprite, ImageSprite, RectSprite, VideoSprite,
  Stage, PlaybackBar,
});



// versioning-scene.jsx — Nature Fresh Farms "Devours" versioning-at-scale showcase.
// Mounted via <x-import component-from-global-scope="VersioningScene"
//   from="./animations.jsx ./versioning-scene.jsx">.
// Reads timeline engine globals from window (animations.jsx loads first).

// timeline engine globals are in scope from animations.jsx (concatenated above)

// ── Brand ────────────────────────────────────────────────────────────────
const NFF = {
  red:       '#E12433',
  redDeep:   '#B5141F',
  green:     '#2F8A3E',
  greenMid:  '#37A04A',
  greenDeep: '#0E3F23',
  greenInk:  '#0A2E1A',
  cream:     '#FFF7E9',
  yellow:    '#F7C948',
  canvas:    '#E8ECE7',
  panel:     '#FFFFFF',
  line:      '#D6DCD4',
  ink:       '#16241B',
  sub:       '#5C6A60',
};
const DISP  = "'Bricolage Grotesque', 'Trebuchet MS', sans-serif";
const UI    = "'Space Grotesk', system-ui, sans-serif";
const PHOTO = 'assets/versioning/devours-lunchbox.png';
const LOGO  = 'assets/versioning/nff-logo.png';
const LOGO_WHITE = 'assets/versioning/nff-logo-white.png';
const LOGO_CUT   = 'assets/versioning/nff-logo-cut.png';

const COPY = {
  full:  "You're building the lunchbox everyone wants to trade for.",
  mid:   'The lunchbox worth trading for.',
  short: 'Trade-worthy fresh.',
  micro: 'The lunchbox MVP.',
};

// ── Shared bits ────────────────────────────────────────────────────────────
function LogoChip({ h = 22 }) {
  return (
    <img src={LOGO_WHITE} alt="Nature Fresh Farms" style={{ height: h, display: 'block', filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.5))' }} />
  );
}

function CTA({ h, label = 'Where to Buy', arrow = true }) {
  const fs = h * 0.46;
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'center', gap: h * 0.2,
      background: NFF.red, color: '#fff',
      fontFamily: UI, fontWeight: 600, fontSize: fs,
      letterSpacing: '-0.01em',
      padding: `${h * 0.18}px ${h * 0.42}px`,
      borderRadius: 999, whiteSpace: 'nowrap',
      boxShadow: `0 ${h*0.1}px ${h*0.34}px rgba(181,20,31,0.3)`,
    }}>
      {label}{arrow && <span style={{ fontSize: fs * 1.05, transform: 'translateY(-1px)' }}>→</span>}
    </div>
  );
}

// The single auto-versioned creative, rendered to any IAB size.
function AdCreative({ w, h, tier }) {
  const ar = w / h;
  const layout = ar >= 2.4 ? (h <= 66 ? 'strip' : 'wide') : (ar <= 0.62 ? 'tower' : 'block');
  const rad = Math.max(3, Math.min(w, h) * 0.04);
  const grad = `linear-gradient(180deg, ${NFF.green} 0%, ${NFF.greenDeep} 118%)`;
  const kicker = (size) => (
    <div style={{
      fontFamily: UI, fontWeight: 700, color: NFF.yellow,
      fontSize: size, letterSpacing: '0.18em', textTransform: 'uppercase',
      lineHeight: 1,
    }}>Devours</div>
  );
  const headline = (text, size, color = NFF.cream, w2) => (
    <div style={{
      fontFamily: DISP, fontWeight: 800, color, fontSize: size,
      lineHeight: 1.02, letterSpacing: '-0.02em', textWrap: 'balance',
      maxWidth: w2 || '100%',
    }}>{text}</div>
  );

  if (layout === 'block') {
    const big = tier === 'master' || (w >= 320 && h >= 280);
    const photoH = tier === 'master' ? 0.62 : (big ? 0.6 : (h >= 240 ? 0.62 : 0.54));
    const pad = Math.max(8, w * 0.05);
    const copy = tier === 'master' ? COPY.full : (big ? COPY.mid : COPY.short);
    const hl = (tier === 'master' ? w * 0.06 : (big ? w * 0.072 : w * 0.084));
    const ctaH = Math.max(13, h * 0.078);
    const logoH = Math.max(18, h * 0.092);
    return (
      <div style={{ position: 'absolute', inset: 0, background: grad, overflow: 'hidden', fontFamily: UI }}>
        <div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${photoH * 100}%`, overflow: 'hidden' }}>
          <img src={PHOTO} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '52% 38%', display: 'block' }} />
          <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, rgba(7,40,22,0.24) 0%, transparent 30%, transparent 88%, ${NFF.green} 100%)` }} />
        </div>
        <div style={{ position: 'absolute', top: pad * 0.7, left: pad * 0.7 }}><LogoChip h={logoH} /></div>
        <div style={{ position: 'absolute', left: pad, right: pad, top: `${photoH * 100}%`, bottom: pad,
          display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: h * 0.045, paddingTop: h * 0.06, paddingBottom: h * 0.03 }}>
          {headline(copy, hl)}
          <div style={{ marginTop: h * 0.025 }}><CTA h={ctaH} arrow={w >= 250} /></div>
        </div>
      </div>
    );
  }

  if (layout === 'tower') {
    const photoH = h >= 560 ? 0.5 : 0.54;
    const pad = Math.max(8, w * 0.085);
    const big = h >= 560;
    const narrowTower = w < 210;
    const copy = narrowTower ? COPY.mid : (big ? COPY.full : (h >= 380 ? COPY.mid : COPY.short));
    const hl = big ? (narrowTower ? w * 0.15 : w * 0.108) : w * 0.125;
    const ctaH = Math.max(13, w * 0.108);
    const logoH = Math.max(16, w * 0.12);
    return (
      <div style={{ position: 'absolute', inset: 0, background: grad, overflow: 'hidden', fontFamily: UI }}>
        <div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${photoH * 100}%`, overflow: 'hidden' }}>
          <img src={PHOTO} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '52% 40%', display: 'block' }} />
          <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, rgba(7,40,22,0.22) 0%, transparent 34%, transparent 88%, ${NFF.green} 100%)` }} />
        </div>
        <div style={{ position: 'absolute', top: pad * 0.7, left: '50%', transform: 'translateX(-50%)' }}><LogoChip h={logoH} /></div>
        <div style={{ position: 'absolute', left: pad, right: pad, top: `${photoH * 100}%`, bottom: pad,
          display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: h * 0.028, paddingTop: h * 0.045, overflow: 'hidden' }}>
          {headline(copy, hl)}
          <div style={{ display: 'flex', justifyContent: 'flex-start', marginTop: h * 0.01 }}><CTA h={ctaH} /></div>
        </div>
      </div>
    );
  }

  if (layout === 'wide') { // 970×250 (tall) and 970×90 / 728×90 / 320×100 (compact)
    const compact = h < 150;
    const pad = Math.max(8, h * 0.13);
    const photoWpx = Math.min(w * 0.46, h * 2.6);
    const logoH = clamp(h * 0.44, 16, 34);
    const hl = clamp(h * 0.26, 13, w * 0.05);
    const ctaH = clamp(h * 0.34, 15, 34);
    const copy = h >= 170 ? COPY.mid : COPY.short;
    if (compact) {
      const narrow = w < 460;          // 320×100 etc — no photo, more headline room
      const cH = clamp(h * (narrow ? 0.32 : 0.4), 14, narrow ? 30 : 38);
      const lH = clamp(h * (narrow ? 0.36 : 0.44), 14, narrow ? 26 : 34);
      const copyC = narrow ? COPY.short : (h >= 170 ? COPY.mid : COPY.short);
      const hlC = clamp(h * 0.24, 12, narrow ? 17 : 22);
      return (
        <div style={{ position: 'absolute', inset: 0, background: grad, overflow: 'hidden', fontFamily: UI }}>
          {!narrow && (
            <React.Fragment>
              <img src={PHOTO} style={{ position: 'absolute', top: 0, right: 0, height: '100%', width: '52%', objectFit: 'cover', objectPosition: '50% 40%', display: 'block' }} />
              <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(90deg, ${NFF.green} 0%, ${NFF.green} 48%, rgba(47,138,62,0) 82%)` }} />
            </React.Fragment>
          )}
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', gap: pad, padding: `0 ${pad}px` }}>
            <div style={{ flexShrink: 0 }}><LogoChip h={lH} /></div>
            <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: h * 0.03 }}>
              {headline(copyC, hlC, NFF.cream, '100%')}
            </div>
            <div style={{ flexShrink: 0 }}><CTA h={cH} arrow={w >= 520} /></div>
          </div>
        </div>
      );
    }
    return (
      <div style={{ position: 'absolute', inset: 0, background: grad, overflow: 'hidden', fontFamily: UI }}>
        <div style={{ position: 'absolute', top: 0, right: 0, height: '100%', width: photoWpx, overflow: 'hidden' }}>
          <img src={PHOTO} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '52% 40%', display: 'block' }} />
          <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(90deg, ${NFF.green} 0%, transparent 52%)` }} />
        </div>
        <div style={{ position: 'absolute', left: pad, top: pad }}><LogoChip h={logoH} /></div>
        <div style={{ position: 'absolute', left: pad, bottom: pad, width: w - photoWpx - pad * 1.5,
          display: 'flex', flexDirection: 'column', gap: h * 0.05 }}>
          {headline(COPY.mid, hl, NFF.cream, '100%')}
          <div style={{ marginTop: h * 0.02 }}><CTA h={ctaH} /></div>
        </div>
      </div>
    );
  }

  // strip — thin horizontal banners (468×60, 320×50, 300×50)
  const pad = Math.max(4, h * 0.18);
  const photoW = h * 1.05;
  const logoH = clamp(h * 0.56, 13, 32);
  const ctaH = clamp(h * 0.5, 12, 30);
  const showHead = w >= 440;
  const hl = h * 0.42;
  return (
    <div style={{ position: 'absolute', inset: 0, background: grad, overflow: 'hidden', fontFamily: UI,
      display: 'flex', alignItems: 'center', gap: pad, paddingRight: pad }}>
      <div style={{ height: '100%', width: photoW, overflow: 'hidden', flexShrink: 0 }}>
        <img src={PHOTO} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '52% 40%', display: 'block' }} />
      </div>
      <div style={{ flexShrink: 0 }}><LogoChip h={logoH} /></div>
      {showHead
        ? <div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>{headline(COPY.short, hl)}</div>
        : <div style={{ flex: 1 }} />}
      <div style={{ flexShrink: 0 }}><CTA h={ctaH} arrow={false} /></div>
    </div>
  );
}

// Wrap an AdCreative at native size and scale it down for display.
function ScaledAd({ w, h, scale, ...rest }) {
  return (
    <div style={{ width: w * scale, height: h * scale, position: 'relative' }}>
      <div style={{ position: 'absolute', top: 0, left: 0, width: w, height: h, transform: `scale(${scale})`, transformOrigin: 'top left' }}>
        <AdCreative w={w} h={h} {...rest} />
      </div>
    </div>
  );
}

// ── Design-preview tool chrome ───────────────────────────────────────────
function ToolWindow({ x, y, w, h, title, tab, children, opacity = 1 }) {
  const bar = 46;
  return (
    <div style={{ position: 'absolute', left: x, top: y, width: w, height: h, opacity,
      background: NFF.panel, borderRadius: 16, overflow: 'hidden',
      boxShadow: '0 30px 80px rgba(14,63,35,0.18), 0 4px 14px rgba(0,0,0,0.08)',
      border: `1px solid ${NFF.line}`, fontFamily: UI }}>
      <div style={{ height: bar, background: `linear-gradient(180deg, ${NFF.green}, ${NFF.greenDeep})`,
        display: 'flex', alignItems: 'center', padding: '0 18px', gap: 14, color: '#fff' }}>
        <div style={{ display: 'flex', gap: 7 }}>
          {['#ff5f57', '#febc2e', '#28c840'].map((c, i) => (
            <div key={i} style={{ width: 11, height: 11, borderRadius: 6, background: c, opacity: 0.92 }} />
          ))}
        </div>
        <div style={{ fontWeight: 600, fontSize: 14, letterSpacing: '-0.01em' }}>{title}</div>
        <div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
          <span style={{ fontSize: 11.5, opacity: 0.85, fontWeight: 500 }}>{tab}</span>
          <div style={{ width: 30, height: 18, borderRadius: 9, background: 'rgba(255,255,255,0.25)', position: 'relative' }}>
            <div style={{ position: 'absolute', top: 2, right: 2, width: 14, height: 14, borderRadius: 7, background: '#fff' }} />
          </div>
        </div>
      </div>
      <div style={{ position: 'absolute', top: bar, left: 0, right: 0, bottom: 0 }}>{children}</div>
    </div>
  );
}

// ── Size grid shelf-packing ─────────────────────────────────────────────
const SIZES = [
  { label: '300×1050', w: 300, h: 1050 },
  { label: '300×600',  w: 300, h: 600 },
  { label: '160×600',  w: 160, h: 600 },
  { label: '120×600',  w: 120, h: 600 },
  { label: '970×250',  w: 970, h: 250 },
  { label: '336×280',  w: 336, h: 280 },
  { label: '300×250',  w: 300, h: 250 },
  { label: '250×250',  w: 250, h: 250 },
  { label: '200×200',  w: 200, h: 200 },
  { label: '320×100',  w: 320, h: 100 },
  { label: '728×90',   w: 728, h: 90 },
  { label: '970×90',   w: 970, h: 90 },
  { label: '468×60',   w: 468, h: 60 },
  { label: '320×50',   w: 320, h: 50 },
  { label: '300×50',   w: 300, h: 50 },
  { label: '240×400',  w: 240, h: 400 },
];

function packShelves(sizes, gScale, maxW, gap) {
  // sizes pre-sorted by display height desc; greedy shelf pack, center rows.
  const rows = [];
  let row = [], rowW = 0, rowH = 0;
  for (const s of sizes) {
    const dw = s.w * gScale, dh = s.h * gScale;
    if (row.length && rowW + gap + dw > maxW) {
      rows.push({ items: row, rowW: rowW, rowH });
      row = []; rowW = 0; rowH = 0;
    }
    row.push({ ...s, dw, dh });
    rowW += (row.length > 1 ? gap : 0) + dw;
    rowH = Math.max(rowH, dh);
  }
  if (row.length) rows.push({ items: row, rowW, rowH });
  return rows;
}

function VersioningScene() {
  const DUR = 26;
  return (
    <Stage width={1920} height={1080} duration={DUR} background={NFF.canvas} fps={60} chrome={true}>
      <ShotMaster start={0} end={8.4} />
      <ShotFanout start={8} end={15.2} />
      <ShotGrid start={14.8} end={22.6} />
      <ShotEnd start={22.2} end={DUR} />
      <Watermark />
    </Stage>
  );
}

// persistent corner label
function Watermark() {
  return (
    <div style={{ position: 'absolute', left: 38, bottom: 30, display: 'flex', alignItems: 'center', gap: 12,
      fontFamily: UI, color: NFF.sub, fontSize: 14, letterSpacing: '0.02em' }}>
      <span style={{ width: 8, height: 8, borderRadius: 4, background: NFF.green }} />
      Adaptive creative engine · one master, every placement
    </div>
  );
}

// ── SHOT A — Master ─────────────────────────────────────────────────────
function ShotMaster({ start, end }) {
  return (
    <Sprite start={start} end={end}>
      {({ localTime }) => {
        const t = localTime;
        const winIn = Easing.easeOutCubic(clamp(t / 0.8, 0, 1));
        const exit = clamp((t - (end - start - 0.6)) / 0.6, 0, 1);
        const op = (1 - exit);
        return (
          <div style={{ position: 'absolute', inset: 0, opacity: op }}>
            <div style={{ position: 'absolute', top: 70, left: 80, right: 80, textAlign: 'left',
              transform: `translateY(${(1 - winIn) * -20}px)`, opacity: winIn }}>
              <div style={{ fontFamily: UI, color: NFF.green, fontWeight: 700, fontSize: 15, letterSpacing: '0.22em', textTransform: 'uppercase' }}>Nature Fresh Farms · Devours</div>
              <div style={{ fontFamily: DISP, color: NFF.ink, fontWeight: 800, fontSize: 40, letterSpacing: '-0.02em', marginTop: 8 }}>One master creative.</div>
            </div>

            <ToolWindow x={430} y={188} w={1060} h={720} title="Design Preview" tab="Master · Locked"
              opacity={winIn}>
              <MasterInner t={t} />
            </ToolWindow>
          </div>
        );
      }}
    </Sprite>
  );
}

function MasterInner({ t }) {
  // build sequence: photo -> headline -> cta, then "lock" stamp
  const adW = 560, adH = 470, scale = 1;
  const reveal = clamp((t - 0.9) / 1.4, 0, 1);
  const stamp = clamp((t - 4.6) / 0.6, 0, 1);
  return (
    <div style={{ position: 'absolute', inset: 0, background: '#F4F6F3', display: 'flex' }}>
      {/* left sidebar */}
      <div style={{ width: 230, borderRight: `1px solid ${NFF.line}`, padding: '22px 20px', background: '#fff', display: 'flex', flexDirection: 'column', gap: 18 }}>
        <div style={{ fontFamily: UI, fontWeight: 700, fontSize: 12, color: NFF.sub, letterSpacing: '0.14em', textTransform: 'uppercase' }}>Brand kit</div>
        <div style={{ display: 'flex', gap: 8 }}>
          {[NFF.green, NFF.red, NFF.yellow, NFF.greenDeep].map((c, i) => (
            <div key={i} style={{ flex: 1, height: 34, borderRadius: 7, background: c, border: '1px solid rgba(0,0,0,0.06)' }} />
          ))}
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
          {[['Headline', DISP], ['UI / Labels', UI]].map(([n, f], i) => (
            <div key={i} style={{ border: `1px solid ${NFF.line}`, borderRadius: 8, padding: '9px 11px' }}>
              <div style={{ fontFamily: UI, fontSize: 10, color: NFF.sub, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{n}</div>
              <div style={{ fontFamily: f, fontSize: 17, fontWeight: 700, color: NFF.ink, marginTop: 2 }}>Aa</div>
            </div>
          ))}
        </div>
        <div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 7 }}>
          {['Logo lockup', 'Photography', 'CTA system', 'Tone of voice'].map((n, i) => (
            <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: UI, fontSize: 12, color: NFF.ink }}>
              <span style={{ width: 15, height: 15, borderRadius: 4, background: NFF.green, color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✓</span>{n}
            </div>
          ))}
        </div>
      </div>
      {/* canvas with master ad */}
      <div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
        backgroundImage: `radial-gradient(${NFF.line} 1px, transparent 1px)`, backgroundSize: '22px 22px' }}>
        <div style={{ position: 'relative', width: adW, height: adH, borderRadius: 14, overflow: 'hidden',
          boxShadow: '0 24px 60px rgba(14,63,35,0.22)' }}>
          <AdCreative w={adW} h={adH} tier="master" />
          {/* reveal wipe */}
          <div style={{ position: 'absolute', inset: 0, background: '#F4F6F3', transform: `translateX(${reveal * 100}%)`, transformOrigin: 'right' }} />
        </div>
        {/* dims label */}
        <div style={{ position: 'absolute', top: '50%', left: '50%', transform: `translate(-50%, ${adH/2 + 22}px)`,
          fontFamily: UI, fontSize: 12, fontWeight: 600, color: NFF.green, background: '#fff', padding: '4px 10px', borderRadius: 6, border: `1px solid ${NFF.line}` }}>560 × 470 · source</div>
        {/* lock stamp */}
        <div style={{ position: 'absolute', top: 30, right: 34, opacity: stamp, transform: `scale(${0.8 + stamp * 0.2})`,
          fontFamily: UI, fontWeight: 700, fontSize: 13, letterSpacing: '0.1em', textTransform: 'uppercase',
          color: NFF.green, border: `2px solid ${NFF.green}`, borderRadius: 8, padding: '7px 12px', background: 'rgba(255,255,255,0.9)' }}>✓ Approved</div>
      </div>
    </div>
  );
}

// ── Device skeletons (technical wireframe placements) ────────────────────
const SKC = { sk: '#DFE5DF', sk2: '#EBF0EA', edge: '#D6DCD4' };
function Sk({ w = '100%', h = 8, r = 4, c, style }) {
  return <div style={{ width: w, height: h, borderRadius: r, background: c || SKC.sk, flexShrink: 0, ...style }} />;
}
function Dots() {
  return <div style={{ display: 'flex', gap: 4 }}>{['#ff5f57', '#febc2e', '#28c840'].map((c, i) => <div key={i} style={{ width: 7, height: 7, borderRadius: 4, background: c }} />)}</div>;
}
function AdSlot({ ad, slotW, ring = true }) {
  const [aw, ah] = ad; const s = slotW / aw;
  return (
    <div style={{ position: 'relative', borderRadius: 4, overflow: 'hidden', boxShadow: '0 5px 14px rgba(14,63,35,0.2)' }}>
      <ScaledAd w={aw} h={ah} scale={s} />
    </div>
  );
}
function DeviceSkeleton({ type, ad }) {
  if (type === 'desktop') {
    const railW = 108, rowH = 200;
    const adH = Math.round(300 * rowH / railW);
    return (
      <div style={{ width: 284, background: '#fff', borderRadius: 10, border: `1px solid ${SKC.edge}`, overflow: 'hidden', boxShadow: '0 16px 36px rgba(0,0,0,0.1)' }}>
        <div style={{ height: 24, background: SKC.sk2, display: 'flex', alignItems: 'center', gap: 6, padding: '0 9px', borderBottom: `1px solid ${SKC.edge}` }}>
          <Dots /><div style={{ flex: 1 }} /><Sk w={130} h={9} r={5} c="#fff" style={{ border: `1px solid ${SKC.edge}` }} />
        </div>
        <div style={{ padding: 13, display: 'flex', gap: 11, alignItems: 'stretch', height: rowH }}>
          <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 7 }}>
            <Sk w={'66%'} h={12} />
            <Sk w={'100%'} h={50} r={6} c={SKC.sk2} />
            <Sk w={'100%'} h={7} /><Sk w={'94%'} h={7} /><Sk w={'98%'} h={7} /><Sk w={'80%'} h={7} />
            <Sk w={'100%'} h={34} r={6} c={SKC.sk2} style={{ marginTop: 3 }} />
            <Sk w={'90%'} h={7} /><Sk w={'72%'} h={7} /><Sk w={'84%'} h={7} />
          </div>
          <div style={{ width: railW, flexShrink: 0, borderRadius: 4, overflow: 'hidden', boxShadow: '0 5px 14px rgba(14,63,35,0.2)' }}>
            <ScaledAd w={300} h={adH} scale={railW / 300} />
          </div>
        </div>
      </div>
    );
  }
  if (type === 'mobile') {
    const pad = 6, sw = 128, sh = 256;
    const screenW = sw - pad * 2;       // 116
    const screenH = sh - pad * 2;       // 244
    const adW = 300, adH = Math.round(adW * screenH / screenW);
    return (
      <div style={{ width: sw, height: sh, background: '#16241b', borderRadius: 22, padding: pad, boxSizing: 'border-box', boxShadow: '0 16px 36px rgba(0,0,0,0.2)' }}>
        <div style={{ width: '100%', height: '100%', background: NFF.green, borderRadius: 16, overflow: 'hidden', position: 'relative', boxSizing: 'border-box' }}>
          {/* Fill the screen edge-to-edge: scale the native creative so its box exactly covers screenW×screenH (stretch both axes — they're near-identical so no visible distortion). */}
          <div style={{ position: 'absolute', top: 0, left: 0, width: adW, height: adH, transform: `scale(${screenW / adW}, ${screenH / adH})`, transformOrigin: 'top left' }}>
            <AdCreative w={adW} h={adH} />
          </div>
          <div style={{ position: 'absolute', top: 7, left: '50%', transform: 'translateX(-50%)', width: 38, height: 5, borderRadius: 3, background: 'rgba(255,255,255,0.9)' }} />
        </div>
      </div>
    );
  }
  if (type === 'tablet') {
    return (
      <div style={{ width: 196, height: 252, background: '#16241b', borderRadius: 16, padding: 7, boxShadow: '0 16px 36px rgba(0,0,0,0.2)' }}>
        <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 9, overflow: 'hidden' }}>
          <div style={{ height: 18, background: SKC.sk2, display: 'flex', alignItems: 'center', padding: '0 8px', gap: 5 }}><Dots /></div>
          <div style={{ padding: 11, display: 'flex', flexDirection: 'column', gap: 9 }}>
            <Sk w={'52%'} h={11} />
            <div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
              <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
                <Sk w={'100%'} h={7} /><Sk w={'90%'} h={7} /><Sk w={'96%'} h={7} /><Sk w={'70%'} h={7} /><Sk w={'100%'} h={28} r={5} c={SKC.sk2} style={{ marginTop: 4 }} />
              </div>
              <AdSlot ad={ad} slotW={92} />
            </div>
            <Sk w={'100%'} h={7} /><Sk w={'84%'} h={7} />
          </div>
        </div>
      </div>
    );
  }
  if (type === 'retail') {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
        <div style={{ width: 284, background: '#0c160f', borderRadius: 9, padding: 8, boxShadow: '0 16px 36px rgba(0,0,0,0.22)' }}>
          <AdSlot ad={ad} slotW={268} ring={false} />
        </div>
        <div style={{ width: 12, height: 18, background: '#1d2b22' }} />
        <div style={{ width: 120, height: 9, borderRadius: 5, background: '#1d2b22' }} />
      </div>
    );
  }
  // social post card
  return (
    <div style={{ width: 212, background: '#fff', borderRadius: 12, border: `1px solid ${SKC.edge}`, overflow: 'hidden', boxShadow: '0 16px 36px rgba(0,0,0,0.1)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 10px' }}>
        <div style={{ width: 24, height: 24, borderRadius: 12, background: `linear-gradient(135deg, ${NFF.green}, ${NFF.greenDeep})` }} />
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><Sk w={74} h={8} /><Sk w={48} h={6} /></div>
      </div>
      <AdSlot ad={ad} slotW={210} ring={false} />
      <div style={{ display: 'flex', gap: 9, padding: '9px 10px 6px' }}>
        {[0, 1, 2].map(i => <div key={i} style={{ width: 15, height: 15, borderRadius: 4, background: SKC.sk }} />)}
      </div>
      <div style={{ padding: '0 10px 11px', display: 'flex', flexDirection: 'column', gap: 5 }}><Sk w={'90%'} h={7} /><Sk w={'66%'} h={7} /></div>
    </div>
  );
}

// ── SHOT B — Fan-out ───────────────────────────────────────────────────
function ShotFanout({ start, end }) {
  const PLACEMENTS = [
    { k: 'Desktop',       type: 'desktop', ad: [300, 600], note: 'Web · 300×600' },
    { k: 'Mobile',        type: 'mobile',  ad: [320, 480], note: 'In-app · 320×480' },
    { k: 'Tablet',        type: 'tablet',  ad: [300, 250], note: 'Tablet · 300×250' },
    { k: 'Retail screen', type: 'retail',  ad: [970, 250], note: 'In-store · 970×250' },
    { k: 'Social',        type: 'social',  ad: [250, 250], note: 'Feed · 250×250' },
  ];
  return (
    <Sprite start={start} end={end}>
      {({ localTime }) => {
        const t = localTime;
        const inOp = Easing.easeOutCubic(clamp(t / 0.5, 0, 1));
        const exit = clamp((t - (end - start - 0.6)) / 0.6, 0, 1);
        const op = inOp * (1 - exit);
        // master node position (top center)
        const masterX = 760, masterY = 132, masterW = 400, masterH = 360;
        const cx = masterX + masterW / 2;
        const cy = masterY + masterH;
        const nodeY = 560;
        const cols = 5, gapX = 60;
        const colW = (1920 - 200 - (cols - 1) * gapX) / cols; // ~284
        const startX = 100;
        return (
          <div style={{ position: 'absolute', inset: 0, opacity: op }}>
            <div style={{ position: 'absolute', top: 40, left: 80, right: 80, textAlign: 'left' }}>
              <div style={{ fontFamily: UI, color: NFF.green, fontWeight: 700, fontSize: 13, letterSpacing: '0.2em', textTransform: 'uppercase' }}>One master · every placement</div>
              <div style={{ fontFamily: DISP, color: NFF.ink, fontWeight: 800, fontSize: 34, letterSpacing: '-0.02em', marginTop: 4 }}>Branches to every placement. Systematically.</div>
            </div>
            {/* connectors */}
            <svg width="1920" height="1080" style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
              {PLACEMENTS.map((p, i) => {
                const nodeCX = startX + i * (colW + gapX) + colW / 2;
                const draw = clamp((t - 0.9 - i * 0.12) / 0.5, 0, 1);
                const midY = (cy + nodeY) / 2;
                const path = `M ${cx} ${cy} C ${cx} ${midY}, ${nodeCX} ${midY}, ${nodeCX} ${nodeY}`;
                const col = i % 2 === 0 ? NFF.green : NFF.red;
                return (
                  <path key={i} d={path} fill="none" stroke={col} strokeWidth="2.5"
                    strokeDasharray="1400" strokeDashoffset={1400 * (1 - draw)} opacity="0.85" />
                );
              })}
              <circle cx={cx} cy={cy} r="6" fill={NFF.green} />
            </svg>
            {/* master node */}
            <div style={{ position: 'absolute', left: masterX, top: masterY, width: masterW, height: masterH }}>
              <div style={{ position: 'absolute', inset: 0, borderRadius: 12, overflow: 'hidden', boxShadow: '0 16px 40px rgba(14,63,35,0.2)' }}>
                <AdCreative w={masterW} h={masterH} />
              </div>
              <div style={{ position: 'absolute', top: -26, right: 0, textAlign: 'right', fontFamily: UI, fontWeight: 700, fontSize: 12, color: NFF.green, letterSpacing: '0.1em', textTransform: 'uppercase' }}>Master</div>
            </div>
            {/* placement nodes */}
            {PLACEMENTS.map((p, i) => {
              const nodeX = startX + i * (colW + gapX);
              const pop = Easing.easeOutBack(clamp((t - 1.5 - i * 0.18) / 0.5, 0, 1));
              return (
                <div key={i} style={{ position: 'absolute', left: nodeX, top: nodeY, width: colW, opacity: clamp(pop, 0, 1), transform: `translateY(${(1 - pop) * 26}px)`, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                  <div style={{ fontFamily: UI, fontWeight: 700, fontSize: 13, color: NFF.ink, marginBottom: 12, textAlign: 'center', letterSpacing: '0.04em' }}>{p.k}</div>
                  <DeviceSkeleton type={p.type} ad={p.ad} />
                  <div style={{ marginTop: 12, fontFamily: UI, fontSize: 11, color: NFF.sub, fontWeight: 600, letterSpacing: '0.04em', background: '#fff', border: `1px solid ${NFF.line}`, borderRadius: 6, padding: '3px 8px' }}>{p.note}</div>
                </div>
              );
            })}
          </div>
        );
      }}
    </Sprite>
  );
}

// ── SHOT C — Size grid cascade ───────────────────────────────────────────
function ShotGrid({ start, end }) {
  const gScale = 0.42, gap = 16, colGap = 60;
  // group by aspect family so like shapes stack vertically
  const cls = (s) => { const r = s.w / s.h; return r < 0.85 ? 'vert' : (r < 1.7 ? 'square' : 'land'); };
  const vert = SIZES.filter((s) => cls(s) === 'vert').sort((a, b) => b.h - a.h);
  const square = SIZES.filter((s) => cls(s) === 'square').sort((a, b) => b.w - a.w || b.h - a.h);
  const land = SIZES.filter((s) => cls(s) === 'land').sort((a, b) => b.w - a.w || b.h - a.h);
  // vertical stack: items top→down, centered within the column's max width
  const stackCol = (arr) => {
    let colW = 0, y = 0; const items = [];
    arr.forEach((s) => { const dw = s.w * gScale, dh = s.h * gScale; colW = Math.max(colW, dw); items.push({ ...s, dw, dh, ly: y }); y += dh + gap; });
    items.forEach((it) => { it.offX = 0; });
    return { items, colW, colH: Math.max(0, y - gap) };
  };
  // skyscrapers are too tall to stack, so they sit side-by-side, top-aligned
  const rowShelf = (arr) => {
    let x = 0, rowH = 0; const items = [];
    arr.forEach((s) => { const dw = s.w * gScale, dh = s.h * gScale; items.push({ ...s, dw, dh, lx: x }); x += dw + gap; rowH = Math.max(rowH, dh); });
    return { items, rowW: Math.max(0, x - gap), rowH };
  };
  const vGroup = rowShelf(vert);
  const sCol = stackCol(square);
  const lCol = stackCol(land);
  const blockH = Math.max(vGroup.rowH, sCol.colH, lCol.colH);
  const totalW = vGroup.rowW + colGap + sCol.colW + colGap + lCol.colW;
  const bandTop = 172, bandBottom = 1012;
  const regionTop = bandTop + Math.max(0, ((bandBottom - bandTop) - blockH) / 2);
  const placed = [];
  let order = 0;
  let x0 = (1920 - totalW) / 2;
  vGroup.items.forEach((it) => { placed.push({ ...it, x: x0 + it.lx, y: regionTop, idx: order++ }); });
  x0 += vGroup.rowW + colGap;
  sCol.items.forEach((it) => { placed.push({ ...it, x: x0 + it.offX, y: regionTop + it.ly, idx: order++ }); });
  x0 += sCol.colW + colGap;
  lCol.items.forEach((it) => { placed.push({ ...it, x: x0 + it.offX, y: regionTop + it.ly, idx: order++ }); });
  const total = placed.length;

  return (
    <Sprite start={start} end={end}>
      {({ localTime }) => {
        const t = localTime;
        const inOp = Easing.easeOutCubic(clamp(t / 0.45, 0, 1));
        const exit = clamp((t - (end - start - 0.7)) / 0.7, 0, 1);
        const op = inOp * (1 - exit);
        const cascadeStart = 0.5, perItem = 0.11;
        let shown = 0;
        placed.forEach((p) => { if (t > cascadeStart + p.idx * perItem) shown++; });
        const sizeCount = Math.min(SIZES.length, Math.round(clamp((t - cascadeStart) / (total * perItem), 0, 1) * SIZES.length));
        const versions = sizeCount * 4;
        return (
          <div style={{ position: 'absolute', inset: 0, opacity: op }}>
            {/* header */}
            <div style={{ position: 'absolute', top: 40, left: 80, right: 80, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
              <div>
                <div style={{ fontFamily: UI, color: NFF.green, fontWeight: 700, fontSize: 13, letterSpacing: '0.2em', textTransform: 'uppercase' }}>Rendering all sizes</div>
                <div style={{ fontFamily: DISP, color: NFF.ink, fontWeight: 800, fontSize: 38, letterSpacing: '-0.02em', marginTop: 4 }}>Every IAB format. Same source. Zero re-builds.</div>
              </div>
              <Counter sizeCount={sizeCount} versions={versions} />
            </div>
            {/* grid */}
            {placed.map((p) => {
              const appear = clamp((t - (cascadeStart + p.idx * perItem)) / 0.4, 0, 1);
              const e = Easing.easeOutBack(appear);
              if (appear <= 0) return null;
              return (
                <div key={p.label + p.idx} style={{ position: 'absolute', left: p.x, top: p.y, width: p.dw, height: p.dh,
                  opacity: clamp(appear * 1.4, 0, 1), transform: `scale(${0.7 + 0.3 * e})`, transformOrigin: 'center' }}>
                  <div style={{ position: 'absolute', inset: 0, borderRadius: 6, overflow: 'hidden', boxShadow: '0 8px 22px rgba(14,63,35,0.13)' }}>
                    <ScaledAd w={p.w} h={p.h} scale={gScale} />
                  </div>
                  <div style={{ position: 'absolute', top: -15, left: 0, fontFamily: UI, fontSize: 9.5, fontWeight: 600, color: NFF.sub, letterSpacing: '0.03em' }}>{p.label}</div>
                </div>
              );
            })}
          </div>
        );
      }}
    </Sprite>
  );
}

function Counter({ sizeCount, versions }) {
  return (
    <div style={{ display: 'flex', gap: 14, alignItems: 'stretch' }}>
      {[['Sizes', sizeCount], ['Locales', 4], ['Versions live', versions]].map(([label, val], i) => (
        <div key={i} style={{ background: i === 2 ? NFF.green : '#fff', color: i === 2 ? '#fff' : NFF.ink,
          border: `1px solid ${i === 2 ? NFF.green : NFF.line}`, borderRadius: 12, padding: '12px 18px', minWidth: 92, textAlign: 'center',
          boxShadow: '0 8px 20px rgba(0,0,0,0.06)' }}>
          <div style={{ fontFamily: UI, fontWeight: 800, fontSize: 34, letterSpacing: '-0.03em', fontVariantNumeric: 'tabular-nums', lineHeight: 1 }}>{val}</div>
          <div style={{ fontFamily: UI, fontSize: 10.5, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', marginTop: 5, opacity: i === 2 ? 0.85 : 0.55 }}>{label}</div>
        </div>
      ))}
    </div>
  );
}

// ── SHOT D — End / hero ───────────────────────────────────────────────────
function ShotEnd({ start, end }) {
  return (
    <Sprite start={start} end={end}>
      {({ localTime }) => {
        const t = localTime;
        const inOp = Easing.easeOutCubic(clamp(t / 0.7, 0, 1));
        return (
          <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(150deg, ${NFF.greenMid}, ${NFF.green} 45%, ${NFF.greenDeep})`, opacity: inOp,
            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
            {/* faint version mosaic behind */}
            <div style={{ position: 'absolute', inset: 0, opacity: 0.1, filter: 'blur(2px)' }}>
              <img src={PHOTO} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
            </div>
            <div style={{ position: 'relative', textAlign: 'center', transform: `translateY(${(1 - inOp) * 22}px)` }}>
              <img src={LOGO_WHITE} alt="Nature Fresh Farms" style={{ height: 78, display: 'block', margin: '0 auto 28px', filter: 'drop-shadow(0 2px 10px rgba(0,0,0,0.32))' }} />
              <div style={{ fontFamily: DISP, color: NFF.cream, fontWeight: 800, fontSize: 80, letterSpacing: '-0.03em', lineHeight: 1.0, maxWidth: 1100, textWrap: 'balance' }}>Built once.<br/>Versioned everywhere.</div>
              <div style={{ marginTop: 40, display: 'flex', justifyContent: 'center' }}><CTA h={56} /></div>
            </div>
          </div>
        );
      }}
    </Sprite>
  );
}

window.VersioningScene = VersioningScene;
