// media-slot.jsx — React drop-zone for video files. Replaces the previous
// <media-slot> custom element, which Chrome refused to upgrade via
// createElement (some quirk in our environment). Plain React sidesteps the
// custom-element registration path entirely.
//
// API:
//   <MediaSlot
//     id="weather-video"          // stable id for the preview pipeline
//     placeholder="Drop a video"  // empty-state caption
//     fit="cover"                  // object-fit on the inner <video>
//     onChange={({src, file, duration}) => …}
//   />
//
// Behaviour: drag-and-drop video file, OR click to browse. Once filled, the
// video auto-plays muted in a loop (Reel-preview style). Hover the slot to
// reveal a small × button to clear it. The element's blob URL is held only
// in component state — videos are not persisted across reloads (too big to
// dump into a sidecar JSON).
//
// Externally addressable: each <MediaSlot> stamps a `data-media-slot-id`
// attribute on its outer div so an export pipeline can `querySelector` it
// and grab the inner <video> via the same data hook on the video element.

const ACCEPTED_VIDEO = ['video/mp4', 'video/quicktime', 'video/webm', 'video/x-m4v'];

function MediaSlot({
  id,
  placeholder = 'Drop a video',
  fit = 'cover',
  onChange,
  style,
}) {
  const [fileURL, setFileURL] = React.useState(null);
  const [dragOver, setDragOver] = React.useState(false);
  const inputRef = React.useRef(null);
  const videoRef = React.useRef(null);
  const prevURL = React.useRef(null);

  // Revoke the prior blob URL whenever we get a new one (or unmount).
  React.useEffect(() => {
    return () => {
      if (prevURL.current) {
        try { URL.revokeObjectURL(prevURL.current); } catch (e) {}
      }
    };
  }, []);

  const acceptFile = (file) => {
    if (!file || !file.type.startsWith('video/')) return;
    if (prevURL.current) {
      try { URL.revokeObjectURL(prevURL.current); } catch (e) {}
    }
    const url = URL.createObjectURL(file);
    prevURL.current = url;
    setFileURL(url);
    // Defer onChange until metadata arrives (so duration is real).
    // Handled in the <video onLoadedMetadata>.
    if (onChange) onChange({ src: url, file, duration: 0 });
  };

  const onLoadedMeta = () => {
    if (!videoRef.current) return;
    if (onChange) {
      onChange({
        src: fileURL,
        file: null,
        duration: videoRef.current.duration,
      });
    }
  };

  const onDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    const f = e.dataTransfer.files && e.dataTransfer.files[0];
    if (f) acceptFile(f);
  };

  const onClear = (e) => {
    e.stopPropagation();
    if (prevURL.current) {
      try { URL.revokeObjectURL(prevURL.current); } catch (e) {}
      prevURL.current = null;
    }
    setFileURL(null);
    if (onChange) onChange({ src: null, file: null, duration: 0 });
  };

  // Shared outer wrapper — the slot fills its parent.
  const outerStyle = {
    position: 'absolute',
    inset: 0,
    width: '100%',
    height: '100%',
    overflow: 'hidden',
    background: 'linear-gradient(135deg, #02102B 0%, #0F6D96 60%, #001172 100%)',
    ...style,
  };

  if (fileURL) {
    return (
      <div data-media-slot-id={id} style={outerStyle}>
        <video
          ref={videoRef}
          data-media-slot-id={id}
          src={fileURL}
          autoPlay
          loop
          muted
          playsInline
          preload="auto"
          onLoadedMetadata={onLoadedMeta}
          style={{
            position: 'absolute',
            inset: 0,
            width: '100%',
            height: '100%',
            objectFit: fit,
            display: 'block',
            // Allow clicks (they're harmless on a muted autoplay video) but
            // pointer events stay on for parent overlays' drop handlers.
            pointerEvents: 'none',
          }}
        />
        <button
          type="button"
          onClick={onClear}
          title="Remove video"
          style={{
            position: 'absolute', top: 6, right: 6,
            width: 22, height: 22,
            background: 'rgba(2, 16, 43, 0.7)',
            color: '#61D4F2',
            border: 'none', cursor: 'pointer', padding: 0,
            font: '600 12px/1 "Bai Jamjuree", sans-serif',
            zIndex: 2,
          }}
        >×</button>
      </div>
    );
  }

  // Empty / drop-zone state.
  return (
    <div
      data-media-slot-id={id}
      style={outerStyle}
      onClick={() => inputRef.current && inputRef.current.click()}
      onDragEnter={(e) => { e.preventDefault(); setDragOver(true); }}
      onDragOver={(e) => { e.preventDefault(); }}
      onDragLeave={() => setDragOver(false)}
      onDrop={onDrop}
    >
      <input
        ref={inputRef}
        type="file"
        accept={ACCEPTED_VIDEO.join(',')}
        style={{ display: 'none' }}
        onChange={(e) => {
          const f = e.target.files && e.target.files[0];
          if (f) acceptFile(f);
          e.target.value = '';
        }}
      />
      <div style={{
        position: 'absolute', inset: 0,
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center', gap: 14,
        color: dragOver ? '#61D4F2' : 'rgba(97, 212, 242, 0.55)',
        fontFamily: '"Bai Jamjuree", sans-serif',
        fontSize: 12, letterSpacing: '0.18em', textTransform: 'uppercase', fontWeight: 600,
        border: `1px dashed ${dragOver ? '#61D4F2' : 'rgba(97, 212, 242, 0.3)'}`,
        background: dragOver ? 'rgba(97, 212, 242, 0.08)' : 'transparent',
        boxSizing: 'border-box',
        padding: 16,
        cursor: 'pointer',
        transition: 'color .15s, border-color .15s, background .15s',
      }}>
        <svg viewBox="0 0 32 32" width="36" height="36" fill="none" style={{ opacity: 0.7 }} aria-hidden="true">
          <rect x="3" y="7" width="20" height="18" stroke="currentColor" strokeWidth="1.4"/>
          <path d="M23 12 L29 8 V24 L23 20 Z" stroke="currentColor" strokeWidth="1.4" fill="none"/>
        </svg>
        <div>{placeholder}</div>
        <div style={{ fontSize: 10, opacity: 0.7, letterSpacing: '0.12em' }}>MP4 · MOV · WEBM</div>
      </div>
    </div>
  );
}

// Make available to templates.jsx and templates-light.jsx (separate Babel
// scripts) via window.
window.MediaSlot = MediaSlot;

// ─────────────────────────────────────────────────────────────────────────
// VideoControls — sidebar UI for scrubbing/playing the loaded video.
// Imperative: it walks the preview node for a <video data-media-slot-id> on
// every poll tick and binds to it. Sidesteps refs through deep templates.
// ─────────────────────────────────────────────────────────────────────────
function VideoControls({ getPreviewNode }) {
  const [video, setVideo] = React.useState(null);
  const [time, setTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [playing, setPlaying] = React.useState(true);

  // Poll for a video element — it may not be there yet on first render, and
  // it changes whenever the slot reloads.
  React.useEffect(() => {
    let active = true;
    const find = () => {
      const node = getPreviewNode && getPreviewNode();
      const v = node && node.querySelector('video[data-media-slot-id]');
      if (!active) return;
      if (v !== video) setVideo(v || null);
    };
    find();
    const t = setInterval(find, 400);
    return () => { active = false; clearInterval(t); };
  }, [getPreviewNode, video]);

  // Bind playback state to local state.
  React.useEffect(() => {
    if (!video) return;
    const onTime = () => setTime(video.currentTime || 0);
    const onDur  = () => setDuration(video.duration || 0);
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    video.addEventListener('timeupdate', onTime);
    video.addEventListener('durationchange', onDur);
    video.addEventListener('loadedmetadata', onDur);
    video.addEventListener('play', onPlay);
    video.addEventListener('pause', onPause);
    // Seed
    onTime(); onDur(); setPlaying(!video.paused);
    return () => {
      video.removeEventListener('timeupdate', onTime);
      video.removeEventListener('durationchange', onDur);
      video.removeEventListener('loadedmetadata', onDur);
      video.removeEventListener('play', onPlay);
      video.removeEventListener('pause', onPause);
    };
  }, [video]);

  if (!video) {
    return (
      <div style={{
        fontFamily: 'var(--font-brand)', fontSize: 11, fontWeight: 500,
        letterSpacing: '0.10em', color: 'rgba(138,153,184,0.7)',
        textTransform: 'none', padding: '4px 0',
      }}>Drop a video onto the preview to enable playback controls.</div>
    );
  }

  const fmt = (s) => {
    if (!isFinite(s) || s < 0) s = 0;
    const m = Math.floor(s / 60); const ss = (s - m * 60).toFixed(1).padStart(4, '0');
    return `${m}:${ss}`;
  };

  return (
    <div>
      <input
        type="range"
        min={0}
        max={duration || 0}
        step={0.05}
        value={Math.min(time, duration || 0)}
        onChange={(e) => {
          const t = parseFloat(e.target.value);
          video.pause();
          try { video.currentTime = t; } catch (err) {}
        }}
        style={{
          width: '100%',
          accentColor: 'var(--blue-light, #61D4F2)',
          margin: '4px 0 6px',
        }}
      />
      <div style={{
        display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        gap: 8,
        fontFamily: 'var(--font-brand)', fontSize: 11, fontWeight: 600,
        letterSpacing: '0.12em', textTransform: 'uppercase',
        color: 'var(--fg-muted, #8A99B8)',
      }}>
        <button
          type="button"
          onClick={() => playing ? video.pause() : video.play().catch(() => {})}
          style={{
            background: 'transparent', border: '1px solid currentColor',
            color: 'inherit', padding: '4px 10px', cursor: 'pointer',
            fontFamily: 'inherit', fontSize: 10, fontWeight: 700,
            letterSpacing: '0.14em', textTransform: 'uppercase',
          }}
        >{playing ? '❚❚ Pause' : '▶ Play'}</button>
        <span style={{ fontVariantNumeric: 'tabular-nums', textTransform: 'none', letterSpacing: '0.06em' }}>
          {fmt(time)} / {fmt(duration)}
        </span>
      </div>
    </div>
  );
}

window.VideoControls = VideoControls;

// ─────────────────────────────────────────────────────────────────────────
// exportFinalVideo — composite the source video with the design overlay into a
// final clip, OFFLINE (frame-accurate, not a real-time screen-record).
//
// 1. Pre-render the design as a sequence of transparent overlay frames across
//    the intro-animation window — driven by the deterministic bake clock so the
//    Rise/Scramble/Compass intros are baked in — then hold the final frame.
// 2. Seek the source video to each exact output time, draw video + the overlay
//    for that moment, and encode a VideoFrame with a uniform timestamp via
//    WebCodecs → fastStart .mp4. No wall-clock playback, so no jitter and the
//    output runs at exactly `fps`. Falls back to a real-time MediaRecorder
//    capture (static overlay) only when WebCodecs is unavailable.
//
// Args:
//   previewNode   The DOM node that contains the design + video.
//   targetW/H     Production resolution (e.g. 1080×1080).
//   maxDuration   Cap (seconds). Defaults to 90.
//   onProgress    Optional (frac, label) callback for UI feedback.
// Returns a Blob (the final video).
// ─────────────────────────────────────────────────────────────────────────
async function exportFinalVideo({ previewNode, targetW, targetH, maxDuration = 90, fps = 30, bg = '#02102B', onProgress }) {
  const video = previewNode.querySelector('video[data-media-slot-id]');
  if (!video) throw new Error('No video loaded. Drop a video on the preview first.');
  if (!video.duration || !isFinite(video.duration)) {
    throw new Error('Video metadata not ready yet — wait a moment and try again.');
  }

  const recDuration = Math.min(maxDuration, video.duration);
  const VIDEO_BITRATE = 12_000_000;
  // The intro animations (Rise/Scramble/Compass) all settle within ~2s. Capture
  // transparent overlay frames across this window, then reuse the last one.
  const ANIM_BAKE_SECONDS = 2.0;
  // The overlay layer animates at most this fast — independent of output fps, to
  // bound rasterisation cost/memory. A short intro at 20fps is plenty smooth.
  const overlayFps = Math.min(fps, 20);

  // object-fit: cover math for drawing the video into an arbitrary target rect.
  const coverInRect = (rx, ry, rw, rh) => {
    const vw = video.videoWidth || rw;
    const vh = video.videoHeight || rh;
    const ra = rw / rh;
    const va = vw / vh;
    let dw, dh, dx, dy;
    if (va > ra) { dh = rh; dw = va * dh; dx = rx + (rw - dw) / 2; dy = ry; }
    else { dw = rw; dh = dw / va; dx = rx; dy = ry + (rh - dh) / 2; }
    return { dx, dy, dw, dh };
  };
  // The footage's rect within the production frame — full-bleed OR inset (light
  // templates frame the photo on a cream surround). Mirrors imageSlotCaptureSpecs:
  // divide by the mobile preview's CSS-transform scale, then scale to target.
  const measureSlotRect = () => {
    const slotEl = video.closest('div[data-media-slot-id]') || video;
    const pr = previewNode.getBoundingClientRect();
    const visScale = previewNode.offsetWidth ? (pr.width / previewNode.offsetWidth) : 1;
    const sc = targetW / previewNode.offsetWidth;
    const sr = slotEl.getBoundingClientRect();
    return {
      x: ((sr.left - pr.left) / visScale) * sc,
      y: ((sr.top - pr.top) / visScale) * sc,
      w: (sr.width / visScale) * sc,
      h: (sr.height / visScale) * sc,
    };
  };

  // Prepare the preview so html-to-image captures ONLY the design as a
  // transparent layer: hide the video pixels + close button + [data-no-export]
  // bits, and punch the slot's background chain transparent so the composited
  // footage shows through. Returns a restore fn. (Unchanged from the previous
  // single-shot capture, just reused across many frames.)
  const beginOverlayCapture = () => {
    const slotWrapper = video.closest('div[data-media-slot-id]');
    const closeBtn = slotWrapper && slotWrapper.querySelector('button');
    const prevVideoVis = video.style.visibility;
    const prevBtnVis = closeBtn && closeBtn.style.visibility;
    video.style.visibility = 'hidden';
    if (closeBtn) closeBtn.style.visibility = 'hidden';
    const hiddenForExport = [...previewNode.querySelectorAll('[data-no-export]')];
    const prevExportVis = hiddenForExport.map((el) => el.style.visibility);
    hiddenForExport.forEach((el) => { el.style.visibility = 'hidden'; });
    const bgChain = [];
    for (let el = video; el && el !== previewNode; el = el.parentElement) {
      bgChain.push([el, el.style.background, el.style.backgroundColor]);
      el.style.background = 'transparent';
    }
    return () => {
      video.style.visibility = prevVideoVis;
      if (closeBtn) closeBtn.style.visibility = prevBtnVis;
      hiddenForExport.forEach((el, i) => { el.style.visibility = prevExportVis[i]; });
      bgChain.forEach(([el, bg, bgc]) => { el.style.background = bg; el.style.backgroundColor = bgc; });
    };
  };

  const overlayScale = targetW / previewNode.offsetWidth;
  const rasterizeOverlay = () => htmlToImage.toPng(previewNode, {
    width: targetW, height: targetH, pixelRatio: 1,
    style: {
      transform: `scale(${overlayScale})`, transformOrigin: 'top left',
      width: previewNode.offsetWidth + 'px', height: previewNode.offsetHeight + 'px',
    },
    // cacheBust would re-fetch the font/CSS on every one of the ~N overlay
    // frames; in video mode there's no image-slot photo to go stale, so let the
    // browser cache the static assets across the sequence.
    cacheBust: false,
  });
  const loadImage = (url) => new Promise((res, rej) => {
    const img = new Image();
    img.onload = () => res(img);
    img.onerror = () => rej(new Error('overlay frame load failed'));
    img.src = url;
  });

  // Scrub every CSS / Web-Animations animation under the preview to time t (in
  // seconds) and pause it — this freezes the CSS-driven Rise intro at that
  // exact moment for the rasteriser. (Scramble/Compass are JS-driven and read
  // the bake clock instead.)
  const scopedAnims = () => {
    const out = [];
    const els = [previewNode, ...previewNode.querySelectorAll('*')];
    for (const el of els) {
      let anims;
      try { anims = el.getAnimations ? el.getAnimations() : []; } catch (e) { anims = []; }
      for (const a of anims) out.push(a);
    }
    return out;
  };
  const freezeCssAnimsAt = (tSeconds) => {
    for (const a of scopedAnims()) {
      try { a.pause(); a.currentTime = tSeconds * 1000; } catch (e) {}
    }
  };
  const thawCssAnims = () => { for (const a of scopedAnims()) { try { a.play(); } catch (e) {} } };
  const settle = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));

  // ───── 1. Pre-render the animated overlay as transparent frames. ─────
  if (onProgress) onProgress(0, 'Rendering graphics');
  const overlayCount = Math.max(1, Math.ceil(Math.min(ANIM_BAKE_SECONDS, recDuration) * overlayFps));
  const overlays = [];
  if (window.__bakeBegin) window.__bakeBegin(fps);
  const restoreOverlay = beginOverlayCapture();
  try {
    for (let k = 0; k < overlayCount; k++) {
      const t = k / overlayFps;
      if (window.__bakeSeek) window.__bakeSeek(t);
      await settle();           // let React commit the bake-clock re-render
      freezeCssAnimsAt(t);      // freeze the CSS Rise intro at t
      overlays.push(await loadImage(await rasterizeOverlay()));
      if (onProgress) onProgress(0.4 * ((k + 1) / overlayCount), `Rendering graphics ${k + 1}/${overlayCount}`);
    }
  } finally {
    restoreOverlay();
    if (window.__bakeEnd) window.__bakeEnd();
    thawCssAnims();
  }
  const staticOverlay = overlays[overlays.length - 1];
  // Map an output frame index → its overlay frame (held static after the intro).
  const overlayFor = (i) => {
    const idx = Math.round((i / fps) * overlayFps);
    return idx < overlays.length ? overlays[idx] : staticOverlay;
  };

  // Compositing canvas.
  const canvas = document.createElement('canvas');
  canvas.width = targetW;
  canvas.height = targetH;
  const ctx = canvas.getContext('2d');
  // Footage rect in the frame (measured now, while the preview is in its normal
  // visible layout). Full-bleed templates → the whole frame; light/inset
  // templates → just the framed photo area, with the theme bg as the surround.
  const slotRect = measureSlotRect();
  const drawComposite = (overlayImg) => {
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, targetW, targetH);
    const { dx, dy, dw, dh } = coverInRect(slotRect.x, slotRect.y, slotRect.w, slotRect.h);
    ctx.save();
    ctx.beginPath();
    ctx.rect(slotRect.x, slotRect.y, slotRect.w, slotRect.h);
    ctx.clip();
    try { ctx.drawImage(video, dx, dy, dw, dh); } catch (e) {}
    ctx.restore();
    if (overlayImg) ctx.drawImage(overlayImg, 0, 0, targetW, targetH);
  };

  // Pause the video and seek deterministically — no real-time playback.
  const origLoop = video.loop, origMuted = video.muted, origPaused = video.paused, origTime = video.currentTime;
  video.loop = false; video.muted = true;
  try { video.pause(); } catch (e) {}
  const restoreVideo = () => {
    video.loop = origLoop;
    video.muted = origMuted;
    try { video.currentTime = origTime; } catch (e) {}
    if (!origPaused) video.play().catch(() => {});
  };
  // Seek to t and wait for the frame to actually be presented before drawing.
  const seekTo = (t) => new Promise((resolve) => {
    let settled = false;
    const finish = () => {
      if (settled) return;
      settled = true;
      video.removeEventListener('seeked', onSeeked);
      resolve();
    };
    const onSeeked = () => {
      if (video.requestVideoFrameCallback) {
        let painted = false;
        video.requestVideoFrameCallback(() => { painted = true; finish(); });
        setTimeout(() => { if (!painted) finish(); }, 300);
      } else {
        requestAnimationFrame(() => requestAnimationFrame(finish));
      }
    };
    video.addEventListener('seeked', onSeeked);
    try { video.currentTime = Math.min(t, Math.max(0, video.duration - 1e-3)); }
    catch (e) { finish(); }
    setTimeout(finish, 2500); // safety: never hang on a missing 'seeked'
  });

  const frameCount = Math.max(1, Math.round(recDuration * fps));
  const frameDurUs = Math.round(1e6 / fps);

  // ───── 2. Offline encode. Preferred: WebCodecs H.264 → fastStart .mp4. ─────
  const Mux = window.Mp4Muxer;
  let mp4Codec = null;
  if (typeof VideoEncoder !== 'undefined' && Mux && Mux.Muxer && Mux.ArrayBufferTarget) {
    for (const c of ['avc1.640028', 'avc1.4d0028', 'avc1.640032', 'avc1.42E01E']) {
      try {
        const r = await VideoEncoder.isConfigSupported({ codec: c, width: targetW, height: targetH, bitrate: VIDEO_BITRATE, framerate: fps });
        if (r && r.supported) { mp4Codec = c; break; }
      } catch (e) {}
    }
  }

  if (mp4Codec) try {
    const muxer = new Mux.Muxer({
      target: new Mux.ArrayBufferTarget(),
      video: { codec: 'avc', width: targetW, height: targetH },
      fastStart: 'in-memory',
    });
    let encErr = null;
    const encoder = new VideoEncoder({
      output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
      error: (e) => { encErr = e; console.error('VideoEncoder error:', e); },
    });
    encoder.configure({ codec: mp4Codec, width: targetW, height: targetH, bitrate: VIDEO_BITRATE, framerate: fps });

    try {
      for (let i = 0; i < frameCount; i++) {
        if (encErr) throw encErr;
        await seekTo(i / fps);
        drawComposite(overlayFor(i));
        // Uniform timestamps + durations → smooth playback at exactly `fps`.
        const frame = new VideoFrame(canvas, { timestamp: i * frameDurUs, duration: frameDurUs });
        encoder.encode(frame, { keyFrame: i % (fps * 2) === 0 });
        frame.close();
        if (onProgress) onProgress(0.4 + 0.6 * ((i + 1) / frameCount), `Rendering frame ${i + 1}/${frameCount}`);
        while (encoder.encodeQueueSize > 30) { await new Promise((r) => setTimeout(r, 10)); }
      }
      await encoder.flush();
      muxer.finalize();
    } finally {
      restoreVideo();
      try { encoder.close(); } catch (e) {}
    }
    if (encErr) throw (encErr.message ? encErr : new Error('Video encode failed'));
    const blob = new Blob([muxer.target.buffer], { type: 'video/mp4' });
    blob.extension = 'mp4';
    if (onProgress) onProgress(1, 'Done');
    return blob;
  } catch (e) {
    // Any failure in the WebCodecs path drops through to a real-time
    // MediaRecorder capture (which on Safari still yields mp4).
    console.warn('WebCodecs offline export failed; falling back to MediaRecorder:', e);
    try { video.currentTime = 0; } catch (e2) {}
  }

  // ───── Fallback: real-time MediaRecorder, STATIC overlay (no WebCodecs). ─────
  // Lower quality and the intro animation is not baked here; rarely hit, since
  // both Chromium and WebKit support the WebCodecs path above.
  const stream = canvas.captureStream(fps);
  const candidates = [
    'video/mp4; codecs=avc1.42E01E',
    'video/mp4',
    'video/webm; codecs=vp9',
    'video/webm; codecs=vp8',
    'video/webm',
  ];
  const mimeType = candidates.find((m) => {
    try { return MediaRecorder.isTypeSupported(m); } catch (e) { return false; }
  }) || 'video/webm';
  const ext = mimeType.startsWith('video/mp4') ? 'mp4' : 'webm';
  const chunks = [];
  const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8_000_000 });
  recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };

  return new Promise((resolve, reject) => {
    let stopped = false;
    let startedAt = 0;
    recorder.onstop = () => {
      restoreVideo();
      const blob = new Blob(chunks, { type: mimeType });
      blob.extension = ext;
      resolve(blob);
    };
    recorder.onerror = (e) => { restoreVideo(); reject(e.error || new Error('Recorder error')); };
    const renderFrame = () => {
      if (stopped) return;
      const elapsed = (performance.now() - startedAt) / 1000;
      if (elapsed >= recDuration || video.ended) {
        stopped = true;
        try { recorder.stop(); } catch (e) {}
        return;
      }
      drawComposite(staticOverlay);
      if (onProgress) onProgress(Math.min(1, elapsed / recDuration), `Recording ${elapsed.toFixed(1)}s / ${recDuration.toFixed(1)}s`);
      requestAnimationFrame(renderFrame);
    };
    video.loop = false; video.muted = true;
    try { video.currentTime = 0; } catch (e) {}
    if (onProgress) onProgress(0, 'Starting playback');
    recorder.start();
    video.play().then(() => {
      startedAt = performance.now();
      requestAnimationFrame(renderFrame);
    }).catch((e) => { restoreVideo(); reject(e); });
  });
}

window.exportFinalVideo = exportFinalVideo;
