/* ===========================================================================
   iRaven App Store Studio — App Preview scene-timeline engine.

   A preview is an ordered list of SCENES. Each scene shows one media item
   (image or video) inside a device frame or full-bleed, for a duration, with
   its own ENTER and EXIT animation (type · duration · easing). Images use a
   chosen on-screen time; videos use their own measured length. A single
   soundtrack spans the whole preview. Multiple previews can be stitched into
   one longer export.

   Rendering reuses the existing low-level machinery from studio-video.jsx
   (html-to-image layer capture, canvas cover-draw, WebCodecs/mp4 exporters,
   AAC soundtrack mux, codec probing) — only the per-frame choreography is new.
   =========================================================================== */

/* screen rect relative to a captured device layer's center (wrapper pad = w*0.18) */
function tlScreenRect(rect, spec) {
  const f = spec.frame;
  const pad = (rect.w - (rect.w / 1.36)) / 2;
  const devW = rect.w - pad * 2;
  const devH = rect.h - pad * 2;
  const bez = devW * f.bezelRatio;
  return { sx: -devW / 2 + bez, sy: -devH / 2 + bez, sw: devW - bez * 2, sh: devH - bez * 2,
    innerR: devW * f.radiusRatio - bez, island: !!f.island };
}

function tlCaptionStyle(pos, dims) {
  const common = { position: 'absolute', left: dims.w * 0.07, width: dims.w * 0.86,
    display: 'flex', justifyContent: 'center' };
  if (pos === 'bottom') return { ...common, bottom: dims.h * 0.07, top: 'auto' };
  return { ...common, top: dims.h * 0.064 };
}

/* ---------- composition used for layer capture + thumbnails ---------- */
function SceneComposition({ preview, sceneIdx, mode }) {
  const device = preview.device || 'iphone';
  const vd = window.VIDEO_DEVICES[device] || window.VIDEO_DEVICES.iphone;
  const dims = vd.canvas;
  const spec = window.DEVICES[device] || window.DEVICES.iphone;
  const accent = window.resolveAccent(preview.accent);
  const isCapture = mode === 'capture';
  const scenes = preview.scenes || [];

  const devW = dims.w * 0.74;
  const pad = devW * 0.18;
  const f = spec.frame;
  const bezel = devW * f.bezelRatio;
  const screenW = devW - bezel * 2;
  const screenH = screenW / spec.screenAspect;
  const devH = screenH + bezel * 2;
  const wrapH = devH + pad * 2;
  const devTop = dims.h * 0.5 - wrapH / 2 + dims.h * 0.03;
  const devLeft = (dims.w - devW) / 2 - pad;

  /* display mode (thumbnail / preview poster): show one representative scene */
  const showIdx = sceneIdx == null ? 0 : sceneIdx;
  const sc = scenes[Math.min(showIdx, Math.max(0, scenes.length - 1))] || window.makeScene({});
  const displaySrc = !isCapture ? window.mediaSrcOf(sc.media) : null;
  const displayVideo = !isCapture && window.isVideoMedia(sc.media);
  if (!isCapture) window.useMediaWarm([sc.media]);

  return (
    <div style={{ position: 'relative', width: dims.w, height: dims.h, overflow: 'hidden', fontFamily: BODY }}>
      <div data-layer="bg" style={{ position: 'absolute', inset: 0 }}>
        <BackgroundLayer bg={preview.bg} dims={dims} />
        <Atmosphere dims={dims} />
      </div>

      {/* device frame layer (used by device-layout scenes) */}
      <div data-layer="device" style={{ position: 'absolute', left: devLeft, top: devTop, padding: pad }}>
        <div style={{ position: 'relative' }}>
          <DeviceFrame device={spec} width={devW}
            image={isCapture ? null : (sc.layout !== 'fullbleed' && !displayVideo ? displaySrc : null)}
            emptyScreen={isCapture || (sc.layout === 'fullbleed') || displayVideo || !displaySrc} />
        </div>
      </div>

      {/* full-bleed display media */}
      {!isCapture && sc.layout === 'fullbleed' && displaySrc && !displayVideo ? (
        <img src={displaySrc} crossOrigin="anonymous" style={{ position: 'absolute', inset: 0,
          width: '100%', height: '100%', objectFit: 'cover' }} />
      ) : null}
      {!isCapture && displayVideo ? (
        <span style={{ position: 'absolute', left: '50%', top: '46%', transform: 'translate(-50%,-50%)',
          padding: `${dims.w * 0.012}px ${dims.w * 0.03}px`, borderRadius: 999, background: 'rgba(0,0,0,0.6)',
          border: '1px solid rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.8)',
          fontFamily: "'JetBrains Mono',monospace", fontSize: dims.w * 0.03 }}>▶ video</span>
      ) : null}

      {/* caption layers — every scene during capture; only the shown scene in display */}
      {scenes.map((s, i) => {
        const visible = isCapture || i === showIdx;
        return (visible && s.captionPos !== 'none' && (s.headline || s.subtitle)) ? (
          <div key={i} data-layer={'cap' + i} style={tlCaptionStyle(s.captionPos, dims)}>
            <Headline slide={{ headline: s.headline, subtitle: s.subtitle, titleSize: s.titleSize }}
              dims={dims} accentColor={accent} align="center" baseRatio={0.075} maxWidth={0.86} />
          </div>
        ) : null;
      })}

      {/* outro end-card */}
      {preview.outro !== false ? (
        <div data-layer="outro" style={{ position: 'absolute', left: 0, right: 0, top: '40%',
          display: 'flex', flexDirection: 'column', alignItems: 'center', gap: dims.w * 0.022,
          opacity: isCapture ? 1 : 0, pointerEvents: 'none' }}>
          {(window.CURRENT_BRAND || {}).logo ? (
            <img src={window.CURRENT_BRAND.logo} crossOrigin="anonymous"
              style={{ maxWidth: dims.w * 0.52, maxHeight: dims.w * 0.26, objectFit: 'contain' }} />
          ) : (
            <span style={{ fontFamily: DISPLAY, fontWeight: 700, fontSize: dims.w * 0.13, letterSpacing: '-0.02em' }}>
              {(() => { const [a, b] = window.splitBrandName((window.CURRENT_BRAND || {}).name);
                return (<><span style={{ color: window.resolveAccent('brand1') }}>{a}</span>
                  <span style={{ color: window.resolveAccent('brand2') }}>{b}</span></>); })()}
            </span>
          )}
          <span style={{ fontFamily: BODY, fontSize: dims.w * 0.035, color: 'rgba(255,255,255,0.72)' }}>
            {(window.CURRENT_BRAND || {}).tagline || ''}
          </span>
        </div>
      ) : null}
    </div>
  );
}

/* ---------- env capture ---------- */
const TL_CAPTURE_STYLE = { position: 'static', left: '0px', top: '0px', right: 'auto', bottom: 'auto',
  margin: '0', transform: 'none', opacity: '1' };

async function tlLoadMedia(media, _urls, mediaErrors) {
  if (!media) return null;
  /* path-string video, dataURL/string image, or storage refs */
  if (typeof media === 'string' && window.isVideoPath(media)) {
    const el = document.createElement('video');
    el.muted = true; el.playsInline = true; el.preload = 'auto'; el.loop = false; el.crossOrigin = 'anonymous';
    el.src = window.resolveAsset(media);
    await new Promise(res => { el.onloadeddata = res; el.onerror = res; setTimeout(res, 6000); });
    const ok = !!(el.videoWidth && el.readyState >= 2);
    if (!ok) mediaErrors.push(media);
    return ok ? { kind: 'video', el, duration: Math.max(0.6, isFinite(el.duration) ? el.duration : 1) } : null;
  }
  if (typeof media === 'string') {
    const im = new Image(); im.crossOrigin = 'anonymous'; im.src = window.resolveAsset(media);
    try { await im.decode(); } catch (e) {}
    return im.naturalWidth ? { kind: 'image', el: im } : null;
  }
  if (media.kind === 'video') {
    const blob = await window.vidMediaGet(media.key);
    if (!blob) return null;
    const el = document.createElement('video');
    el.muted = true; el.playsInline = true; el.preload = 'auto'; el.loop = false;
    const u = URL.createObjectURL(blob); _urls.push(u); el.src = u;
    await new Promise(res => { el.onloadeddata = res; el.onerror = res; setTimeout(res, 5000); });
    if (el.duration === Infinity) {
      await new Promise(res => { const h = () => { el.removeEventListener('durationchange', h); res(); };
        el.addEventListener('durationchange', h); el.currentTime = 1e9; setTimeout(res, 1500); });
      el.currentTime = 0; await new Promise(r => setTimeout(r, 50));
    }
    const ok = !!(el.videoWidth && el.readyState >= 2);
    if (!ok) mediaErrors.push(media.name || media.key);
    return ok ? { kind: 'video', el, duration: Math.max(0.6, isFinite(el.duration) ? el.duration : 1) } : null;
  }
  if (media.kind === 'image') {
    const url = await window.mediaWarmKey(media.key);
    if (!url) return null;
    const im = new Image(); im.src = url;
    try { await im.decode(); } catch (e) {}
    return im.naturalWidth ? { kind: 'image', el: im } : null;
  }
  return null;
}

async function captureSceneEnv(preview, opts) {
  opts = opts || {};
  const device = opts.forceDevice || preview.device || 'iphone';
  const pv = { ...preview, device };
  const vd = window.VIDEO_DEVICES[device] || window.VIDEO_DEVICES.iphone;
  const dims = vd.canvas;
  const spec = window.DEVICES[device] || window.DEVICES.iphone;
  const scenes = preview.scenes || [];

  /* warm any storage refs before rendering the capture composition */
  await window.mediaPrewarm([...scenes.map(s => s.media)]);

  const host = document.createElement('div');
  host.style.cssText = 'position:fixed;left:-30000px;top:0;pointer-events:none;';
  document.body.appendChild(host);
  const root = ReactDOM.createRoot(host);
  root.render(<SceneComposition preview={pv} mode="capture" />);
  for (let i = 0; i < 800 && !host.firstChild; i++) await window.vidYield();
  const node = host.firstChild;
  if (!node) { root.unmount(); host.remove(); throw new Error('compose render timeout'); }
  const domImgs = node.querySelectorAll('img');
  await Promise.all([...domImgs].map(im => im.complete && im.naturalWidth
    ? Promise.resolve() : new Promise(r => { im.onload = im.onerror = r; })));
  try { await document.fonts.ready; } catch (e) {}

  const rootRect = node.getBoundingClientRect();
  const fontCss = await window.getFontEmbedCss();
  const layers = {};
  for (const el of node.querySelectorAll('[data-layer]')) {
    const name = el.getAttribute('data-layer');
    const rect = el.getBoundingClientRect();
    const url = await window.htmlToImage.toPng(el, { pixelRatio: 1, fontEmbedCSS: fontCss, style: TL_CAPTURE_STYLE,
      width: Math.round(rect.width), height: Math.round(rect.height) });
    const img = new Image(); img.src = url; await img.decode();
    layers[name] = { img, rect: { x: rect.left - rootRect.left, y: rect.top - rootRect.top, w: rect.width, h: rect.height } };
  }
  root.unmount(); host.remove();

  /* load each scene's media + build the timeline */
  const _urls = [];
  const mediaErrors = [];
  const sceneMedia = [];
  for (const sc of scenes) sceneMedia.push(await tlLoadMedia(sc.media, _urls, mediaErrors));

  const dl = layers.device;
  const deviceCenter = dl ? { x: dl.rect.x + dl.rect.w / 2, y: dl.rect.y + dl.rect.h / 2 } : { x: dims.w / 2, y: dims.h * 0.55 };
  const screenRect = dl ? tlScreenRect(dl.rect, spec) : null;
  const accentColor = window.resolveAccent(preview.accent);

  /* per-scene timing — video scenes adopt their measured length */
  const timeline = [];
  let cursor = 0;
  scenes.forEach((sc, i) => {
    const m = sceneMedia[i];
    let dur;
    if (m && m.kind === 'video') dur = Math.max(1.2, m.duration);
    else dur = Math.max(0.8, sc.dur || 2);
    timeline.push({ start: +cursor.toFixed(3), dur: +dur.toFixed(3), measured: m && m.kind === 'video' ? m.duration : null });
    cursor += dur;
  });
  const body = cursor;
  const hasOutro = preview.outro !== false;
  const outroDur = hasOutro && layers.outro ? 2.4 : 0;
  const duration = +(body + outroDur).toFixed(2);

  const videoSync = [];
  scenes.forEach((sc, i) => { const m = sceneMedia[i];
    if (m && m.kind === 'video') videoSync.push({ el: m.el, start: timeline[i].start, duration: m.duration }); });

  const env = { dims, layers, sceneMedia, timeline, deviceCenter, screenRect, accentColor, spec,
    body, outroDur, hasOutro, duration, videoSync, _urls, mediaErrors, scenes };
  env.drawLocal = (ctx, T) => tlDrawFrame(ctx, T, env, preview);
  return env;
}

/* ---------- per-frame drawing ---------- */
function tlPlaceholder(ctx, sr) {
  ctx.fillStyle = '#0a1326'; ctx.fillRect(sr.sx, sr.sy, sr.sw, sr.sh);
  ctx.fillStyle = 'rgba(255,255,255,0.32)';
  ctx.font = `${Math.round(sr.sw * 0.05)}px "JetBrains Mono", monospace`;
  ctx.textAlign = 'center';
  ctx.fillText('DROP MEDIA', sr.sx + sr.sw / 2, sr.sy + sr.sh / 2);
}

function tlScenePhase(sc, T, start, dur) {
  const end = start + dur;
  const eIn = Math.max(0.001, Math.min((sc.enter && sc.enter.dur) || 0.5, dur * 0.6));
  const eOut = Math.max(0.001, Math.min((sc.exit && sc.exit.dur) || 0.5, dur * 0.6));
  if (T < start + eIn) return { phase: 'in', p: window.vidEase((sc.enter && sc.enter.ease) || 'out', (T - start) / eIn), type: (sc.enter && sc.enter.type) || 'fade', amt: (sc.enter && sc.enter.dist != null) ? sc.enter.dist : 1 };
  if (T > end - eOut) return { phase: 'out', p: window.vidEase((sc.exit && sc.exit.ease) || 'in', (end - T) / eOut), type: (sc.exit && sc.exit.type) || 'fade', amt: (sc.exit && sc.exit.dist != null) ? sc.exit.dist : 1 };
  return { phase: 'hold', p: 1, type: 'fade', amt: 1 };
}

function tlDrawFrame(ctx, T, env, preview) {
  const { dims } = env;
  ctx.clearRect(0, 0, dims.w, dims.h);
  ctx.fillStyle = '#03050e'; ctx.fillRect(0, 0, dims.w, dims.h);

  /* background (always) */
  if (env.layers.bg) ctx.drawImage(env.layers.bg.img, 0, 0, dims.w, dims.h);

  /* active scene */
  let idx = -1;
  for (let i = 0; i < env.timeline.length; i++) {
    const t = env.timeline[i];
    if (T >= t.start && T < t.start + t.dur) { idx = i; break; }
  }
  if (idx === -1 && T < env.body) idx = env.timeline.length - 1;

  if (idx >= 0) {
    const sc = env.scenes[idx];
    const tl = env.timeline[idx];
    const ph = tlScenePhase(sc, T, tl.start, tl.dur);
    const st = window.sceneAnimState(ph.type, ph.p, ph.amt);
    const m = env.sceneMedia[idx];

    if (sc.layout === 'fullbleed') {
      ctx.save();
      ctx.globalAlpha = Math.min(1, Math.max(0, st.o));
      ctx.translate(dims.w / 2 + st.x * dims.w, dims.h / 2 + st.y * dims.h);
      ctx.rotate((st.r || 0) * Math.PI / 180);
      ctx.scale(st.s, st.s);
      ctx.translate(-dims.w / 2, -dims.h / 2);
      if (m && m.el) window.vidDrawCover(ctx, m.el, 0, 0, dims.w, dims.h);
      else { ctx.fillStyle = '#0a1326'; ctx.fillRect(0, 0, dims.w, dims.h); }
      ctx.restore();
      /* scrim so a bottom caption stays legible */
      if (sc.captionPos === 'bottom') {
        const g = ctx.createLinearGradient(0, 0, 0, dims.h);
        g.addColorStop(0.40, 'rgba(3,5,14,0.12)'); g.addColorStop(0.86, 'rgba(3,5,14,0.92)');
        ctx.save(); ctx.globalAlpha = st.o; ctx.fillStyle = g; ctx.fillRect(0, 0, dims.w, dims.h); ctx.restore();
      }
    } else {
      const dl = env.layers.device, sr = env.screenRect;
      if (dl && sr) {
        ctx.save();
        ctx.globalAlpha = Math.min(1, Math.max(0, st.o));
        ctx.translate(env.deviceCenter.x + st.x * dims.w, env.deviceCenter.y + st.y * dims.h);
        ctx.rotate((st.r || 0) * Math.PI / 180);
        ctx.scale(st.s, st.s);
        /* glow */
        const rad = dims.w * 0.6;
        const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, rad);
        gg.addColorStop(0, env.accentColor + '40'); gg.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.save(); ctx.globalAlpha = st.o * 0.5; ctx.fillStyle = gg;
        ctx.fillRect(-dims.w, -dims.h, dims.w * 2, dims.h * 2); ctx.restore();
        ctx.drawImage(dl.img, -dl.rect.w / 2, -dl.rect.h / 2, dl.rect.w, dl.rect.h);
        ctx.save();
        window.vidRoundRect(ctx, sr.sx, sr.sy, sr.sw, sr.sh, sr.innerR); ctx.clip();
        ctx.fillStyle = '#000'; ctx.fillRect(sr.sx, sr.sy, sr.sw, sr.sh);
        if (m && m.el) window.vidDrawCover(ctx, m.el, sr.sx, sr.sy, sr.sw, sr.sh);
        else tlPlaceholder(ctx, sr);
        if (sr.island) { const iw = sr.sw * 0.34, ih = sr.sw * 0.092; ctx.fillStyle = '#000';
          window.vidRoundRect(ctx, -iw / 2, sr.sy + sr.sh * 0.018, iw, ih, ih / 2); ctx.fill(); }
        ctx.restore();
        ctx.restore();
      }
    }

    /* caption */
    const cap = env.layers['cap' + idx];
    if (cap) {
      const capDy = ph.phase === 'in' ? (1 - ph.p) * dims.h * 0.035 : 0;
      ctx.save(); ctx.globalAlpha = Math.min(1, Math.max(0, ph.p));
      ctx.drawImage(cap.img, cap.rect.x, cap.rect.y + capDy, cap.rect.w, cap.rect.h);
      ctx.restore();
    }
  }

  /* outro */
  if (env.hasOutro && env.layers.outro && T >= env.body - 0.4) {
    const oS = env.body;
    const dim = window.vidEase('out', Math.min(1, Math.max(0, (T - (oS - 0.4)) / 0.7))) * 0.82;
    ctx.save(); ctx.globalAlpha = dim; ctx.fillStyle = '#03050e'; ctx.fillRect(0, 0, dims.w, dims.h); ctx.restore();
    const o = env.layers.outro;
    const p = window.vidEase('out', Math.min(1, Math.max(0, (T - (oS + 0.25)) / 0.7)));
    if (p > 0.001) {
      const y = (1 - p) * 24, s = 0.94 + 0.06 * p;
      ctx.save(); ctx.globalAlpha = p;
      ctx.translate(o.rect.x + o.rect.w / 2, o.rect.y + o.rect.h / 2 + y);
      ctx.scale(s, s);
      ctx.drawImage(o.img, -o.rect.w / 2, -o.rect.h / 2, o.rect.w, o.rect.h);
      ctx.restore();
    }
  }
}

/* ---------- media time sync (preview + record) ---------- */
function tlSync(videoSync, T) {
  for (const s of videoSync || []) {
    const local = T - s.start;
    if (local < 0 || local > s.duration + 0.12) {
      if (!s.el.paused) { try { s.el.pause(); } catch (e) {} }
      if (local < 0 && s.el.currentTime > 0.05) { try { s.el.currentTime = 0; } catch (e) {} }
      continue;
    }
    if (s.el.paused) { try { s.el.play().catch(() => {}); } catch (e) {} }
    if (Math.abs(s.el.currentTime - local) > 0.34) { try { s.el.currentTime = Math.min(local, s.duration); } catch (e) {} }
  }
}

/* ---------- generic exporter (single preview OR stitched segments) ---------- */
/* segs: [{ env, offset }]  · combined videoSync built from each env + offset */
async function recordTimeline({ dims, D, segs, audioKey, onStatus }) {
  const canvas = document.createElement('canvas');
  canvas.width = dims.w; canvas.height = dims.h;
  const ctx = canvas.getContext('2d');

  const videoSync = [];
  for (const sg of segs) for (const s of sg.env.videoSync || []) videoSync.push({ el: s.el, start: s.start + sg.offset, duration: s.duration });

  const drawAt = (T) => {
    let seg = segs[0];
    for (const sg of segs) { if (T >= sg.offset && T < sg.offset + sg.env.duration) { seg = sg; break; } }
    if (T >= segs[segs.length - 1].offset + segs[segs.length - 1].env.duration) seg = segs[segs.length - 1];
    seg.env.drawLocal(ctx, Math.min(seg.env.duration - 0.001, Math.max(0, T - seg.offset)));
  };
  /* Deterministic per-frame seek: await the seeked event so the canvas
     captures the actual decoded frame at time T, not a stale one. This
     is the fix for "frames missing / glued together" exports. tlSync
     (fire-and-forget) is still used for the LIVE preview loop because
     that one is wall-clock paced and a missed seek is harmless visually,
     but the recording loop MUST await. */
  const seekFnAwaited = async (T) => {
    for (const s of videoSync) {
      const local = T - s.start;
      if (local < 0 || local > s.duration + 0.12) continue;
      const want = Math.min(local, s.duration);
      if (Math.abs(s.el.currentTime - want) < 1 / 60) continue;
      await new Promise(res => {
        let done = false;
        const fin = () => { if (done) return; done = true; s.el.removeEventListener('seeked', fin); res(); };
        s.el.addEventListener('seeked', fin);
        try { s.el.currentTime = want; } catch (e) { fin(); }
        setTimeout(fin, 250);
      });
    }
  };
  const hasVideo = videoSync.length > 0;
  const primeAll = async () => { for (const sg of segs) await window.vidPrimeMedia(sg.env); };
  const pauseAll = () => { for (const s of videoSync) { try { s.el.pause(); } catch (e) {} } };

  const aacCfg = await window.vidPickAacConfig();
  const wantAudio = !!audioKey;
  const canMp4 = !!(window.Mp4Muxer && typeof VideoEncoder !== 'undefined');
  let blob = null, ext = 'mp4';

  /* Path A — clean .mp4 via WebCodecs. Used when there's no soundtrack, or when
     the browser CAN encode AAC (Safari, some Chromium) so music goes into mp4. */
  if (canMp4 && (!wantAudio || aacCfg)) {
    let pcm = null;
    if (wantAudio && aacCfg) {
      onStatus && onStatus('Loading soundtrack…');
      pcm = await window.vidLoadAudioPCM(audioKey, D, aacCfg.sampleRate);
      if (!pcm) onStatus && onStatus('Soundtrack failed to decode — exporting silent');
    }
    if (hasVideo) {
      await primeAll();
      blob = await window.vidEncodeMp4Realtime(canvas, drawAt, D, 30, seekFnAwaited, msg => onStatus && onStatus(msg), pcm, aacCfg);
      pauseAll();
    } else {
      blob = await window.vidEncodeMp4(canvas, drawAt, D, 30, p => onStatus && onStatus(`Encoding ${Math.round(p * 100)}%…`), null, pcm, aacCfg);
    }
  }

  /* Path B — soundtrack present but no AAC encoder (e.g. Chrome): record with
     MediaRecorder and mux the music track live, so the music is NEVER dropped.
     Output is .webm (with audio) — convert to .mp4/.mov for the App Store. */
  if (!blob && wantAudio && !aacCfg) {
    onStatus && onStatus('Loading soundtrack…');
    const audio = await window.vidLoadAudioStream(audioKey, D);
    if (!audio) onStatus && onStatus('Soundtrack failed to decode — exporting silent');
    await primeAll();
    onStatus && onStatus('Recording…');
    const res = await window.vidRecordRealtime(canvas, drawAt, D, msg => onStatus && onStatus(msg), syncFn, audio);
    pauseAll();
    if (res) { blob = res.blob; ext = res.ext; }
  }

  /* Path C — last resort (no mp4 support and/or no soundtrack): plain capture. */
  if (!blob) {
    onStatus && onStatus('Recording…');
    await primeAll();
    const res = await window.vidRecordRealtime(canvas, drawAt, D, msg => onStatus && onStatus(msg), syncFn);
    pauseAll();
    if (!res) { onStatus && onStatus('Recording not supported in this browser'); return null; }
    blob = res.blob; ext = res.ext;
  }
  return { blob, ext };
}

/* ---------- live stage (canvas preview + record) ---------- */
const SceneStage = forwardRef(function SceneStage({ preview, idx, scale, activeScene, onMeasured, onExport, onSave, onStatus }, ref) {
  const canvasRef = useRef(null);
  const envRef = useRef(null);
  const audioRef = useRef(null);
  const playRef = useRef(null);
  const busyRef = useRef(false);
  const pendingRef = useRef(false);
  const [ready, setReady] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [pos, setPos] = useState(0);
  const sig = JSON.stringify(preview);
  const dims = (window.VIDEO_DEVICES[preview.device] || window.VIDEO_DEVICES.iphone).canvas;

  const drawAt = (T) => { const c = canvasRef.current; if (c && envRef.current) envRef.current.drawLocal(c.getContext('2d'), T); };

  const stopLoop = () => {
    playRef.current = null; setPlaying(false);
    for (const s of (envRef.current && envRef.current.videoSync) || []) { try { s.el.pause(); } catch (e) {} }
    if (audioRef.current) { try { audioRef.current.pause(); } catch (e) {} }
  };

  const prepare = async () => {
    if (busyRef.current) { pendingRef.current = true; return false; }
    busyRef.current = true; setReady(false);
    const finish = (ok) => { busyRef.current = false;
      if (pendingRef.current) { pendingRef.current = false; setTimeout(() => prepare(), 30); }
      return ok; };
    onStatus && onStatus('Preparing preview…');
    try {
      if (envRef.current) { window.vidDisposeEnv(envRef.current); }
      if (audioRef.current) { try { audioRef.current.pause(); URL.revokeObjectURL(audioRef.current.src); } catch (e) {} audioRef.current = null; }
      const env = await captureSceneEnv(preview);
      envRef.current = env;
      if (preview.audio && preview.audio.key) {
        try { const ab = await window.vidMediaGet(preview.audio.key);
          if (ab) { const a = document.createElement('audio'); a.src = URL.createObjectURL(ab); a.preload = 'auto'; audioRef.current = a; } } catch (e) {}
      }
      const c = canvasRef.current; c.width = env.dims.w; c.height = env.dims.h;
      /* report measured video lengths back so the editor can show them */
      if (onMeasured) onMeasured(env.timeline);
      const seek = activeScene != null && env.timeline[activeScene]
        ? env.timeline[activeScene].start + 0.25 : 0.25;
      drawAt(seek); setPos(seek); setReady(true);
      if (env.mediaErrors && env.mediaErrors.length) { onStatus && onStatus(window.VID_CODEC_MSG); setTimeout(() => onStatus && onStatus(null), 9000); }
      else onStatus && onStatus(null);
      return finish(true);
    } catch (e) { console.error(e); onStatus && onStatus('Preview failed'); return finish(false); }
  };

  useEffect(() => { stopLoop(); const h = setTimeout(prepare, 420); return () => clearTimeout(h); }, [sig]);
  useEffect(() => { /* jump to a scene when selection changes (not playing) */
    if (!playRef.current && envRef.current && activeScene != null && envRef.current.timeline[activeScene]) {
      const t = envRef.current.timeline[activeScene].start + 0.25; drawAt(t); setPos(t);
    }
  }, [activeScene]);
  useEffect(() => () => { stopLoop(); if (envRef.current) window.vidDisposeEnv(envRef.current);
    if (audioRef.current) { try { URL.revokeObjectURL(audioRef.current.src); } catch (e) {} } }, []);

  const play = async () => {
    if (playRef.current) { stopLoop(); return; }
    if (!envRef.current) { const ok = await prepare(); if (!ok) return; }
    const D = envRef.current.duration;
    const token = { start: performance.now() - pos * 1000 };
    if (pos >= D - 0.05) token.start = performance.now();
    playRef.current = token; setPlaying(true);
    let lastF = -1;
    while (playRef.current === token) {
      const T = ((performance.now() - token.start) / 1000);
      if (T >= D) { stopLoop(); drawAt(D - 0.01); setPos(0); break; }
      const f = Math.floor(T * 30);
      if (f !== lastF) {
        lastF = f;
        try {
          tlSync(envRef.current.videoSync, T);
          const a = audioRef.current;
          if (a) { const end = isFinite(a.duration) ? a.duration - 0.05 : Infinity;
            if (T < end) { if (a.paused) { try { a.play().catch(() => {}); } catch (e) {} } if (Math.abs(a.currentTime - T) > 0.45) { try { a.currentTime = T; } catch (e) {} } }
            else if (!a.paused) { try { a.pause(); } catch (e) {} } }
          drawAt(T); setPos(T);
        } catch (e) { console.error(e); }
      }
      await window.vidYield();
    }
  };

  const renderBlob = async () => {
    if (busyRef.current) return null;
    stopLoop();
    if (!envRef.current) { const ok = await prepare(); if (!ok) return null; }
    busyRef.current = true;
    try {
      const env = envRef.current;
      const res = await recordTimeline({ dims: env.dims, D: env.duration,
        segs: [{ env, offset: 0 }], audioKey: preview.audio && preview.audio.key, onStatus });
      drawAt(0.25); setPos(0.25);
      busyRef.current = false;
      return res || null;
    } catch (e) {
      console.error(e); onStatus && onStatus('Export failed');
      busyRef.current = false; return null;
    }
  };

  const record = async () => {
    const res = await renderBlob();
    if (!res || !res.blob) return;
    try {
      if (onExport) await onExport(res.blob, res.ext);
      const a = document.createElement('a'); a.href = URL.createObjectURL(res.blob);
      a.download = `${((window.CURRENT_BRAND || {}).name || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '')}-preview-${preview.device}-${String((idx || 0) + 1).padStart(2, '0')}.${res.ext}`;
      a.click();
      onStatus && onStatus(res.ext === 'mp4' ? 'Saved ✓ .mp4 (raw — run App Store export for upload)' : 'Saved ✓ .webm — run App Store export for upload');
      setTimeout(() => onStatus && onStatus(null), 4000);
    } catch (e) { console.error(e); onStatus && onStatus('Export failed'); }
  };

  /* App Store export — runs the raw render through /api/export so the
     output is guaranteed CFR 30, H.264 high@4.0, yuv420p, AAC 48 kHz,
     and (for App Store presets) hard-capped to 30 seconds. The status
     line shows the server-side validation result. This is the ONLY
     export path that should be used for App Store Connect uploads. */
  const recordAppStore = async (presetKey) => {
    const res = await renderBlob();
    if (!res || !res.blob) return null;
    try {
      const preset = presetKey || window.APP_STORE_DEFAULT_PRESET;
      onStatus && onStatus('Normalising for App Store…');
      const normalized = await window.appStoreNormalize(res.blob, preset);
      if (onExport) await onExport(normalized.blob, 'mp4');
      const a = document.createElement('a'); a.href = URL.createObjectURL(normalized.blob);
      const brand = ((window.CURRENT_BRAND || {}).name || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '');
      a.download = `${brand}-${preset}-${String((idx || 0) + 1).padStart(2, '0')}.mp4`;
      a.click();
      const msg = window.summarizeAppStoreValidation(normalized.validation);
      onStatus && onStatus(msg);
      setTimeout(() => onStatus && onStatus(null), 8000);
      return normalized;
    } catch (e) {
      console.error(e);
      onStatus && onStatus('App Store export failed: ' + (e.message || 'unknown error'));
      return null;
    }
  };

  /* Record on server — the deterministic offline path. Walks the
     timeline frame-by-frame in the browser, awaits each <video> seek,
     captures every frame as JPEG, ships the zip to /api/render. The
     server runs ffmpeg image2 against the sequence so the output is
     CFR 30 regardless of how slow the browser was. This is the right
     path when the WebCodecs realtime export shows missing/repeated
     frames (i.e. the symptom the user reported). */
  const recordOnServer = async (presetKey) => {
    if (busyRef.current) return null;
    stopLoop();
    if (!envRef.current) { const ok = await prepare(); if (!ok) return null; }
    busyRef.current = true;
    try {
      const preset = presetKey || window.APP_STORE_DEFAULT_PRESET;
      const env = envRef.current;
      onStatus && onStatus('Rendering frame 0…');
      const result = await window.renderTimelineOnServer({
        env,
        audioKey: preview.audio && preview.audio.key,
        preset,
        onProgress: (p) => {
          if (p.phase === 'decoding') onStatus && onStatus(`Decoding source video #${(p.sceneIndex ?? 0) + 1}…`);
          else if (p.phase === 'decode-done') {
            const f = p.fallback ? ` · ${p.fallback} slow-path` : '';
            onStatus && onStatus(`Decoded ${p.decoded} source video(s)${f}. Walking timeline…`);
          }
          else if (p.phase === 'rendering') {
            const pct = Math.round((p.percent || (p.frame / p.total)) * 100);
            onStatus && onStatus(`Rendering ${p.frame}/${p.total} (${pct}%)…`);
          } else if (p.phase === 'zipping') onStatus && onStatus('Bundling frames…');
          else if (p.phase === 'uploading') onStatus && onStatus(`Uploading ${(p.size / 1048576).toFixed(1)} MB…`);
          else if (p.phase === 'done') onStatus && onStatus('Encoding on server…');
        },
      });
      if (onExport) await onExport(result.blob, 'mp4');
      const a = document.createElement('a'); a.href = URL.createObjectURL(result.blob);
      const brand = ((window.CURRENT_BRAND || {}).name || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '');
      a.download = `${brand}-${preset}-server-${String((idx || 0) + 1).padStart(2, '0')}.mp4`;
      a.click();
      const msg = window.summarizeAppStoreValidation(result.validation);
      onStatus && onStatus(`Server render done · ${msg}`);
      setTimeout(() => onStatus && onStatus(null), 10000);
      drawAt(0.25); setPos(0.25);
      busyRef.current = false;
      return result;
    } catch (e) {
      console.error(e);
      onStatus && onStatus('Server render failed: ' + (e.message || 'unknown error'));
      busyRef.current = false;
      return null;
    }
  };

  const save = async () => {
    const res = await renderBlob();
    if (!res || !res.blob) return;
    try {
      if (onSave) await onSave(res.blob, res.ext);
      onStatus && onStatus('Saved ✓');
      setTimeout(() => onStatus && onStatus(null), 2500);
    } catch (e) { console.error(e); onStatus && onStatus('Save failed'); }
  };

  useImperativeHandle(ref, () => ({ record, save, recordAppStore, recordOnServer, isReady: () => ready }), [ready]);

  const D = (envRef.current && envRef.current.duration) || window.previewDuration(preview);
  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
      <div style={{ position: 'relative', width: dims.w * scale, height: dims.h * scale,
        boxShadow: '0 40px 120px rgba(0,0,0,0.6)', borderRadius: 6, overflow: 'hidden', background: '#03050e' }}>
        <canvas ref={canvasRef} style={{ width: dims.w * scale, height: dims.h * scale, display: 'block' }} />
        {!playing ? (
          <button onClick={play} title="Play preview" style={{ position: 'absolute', left: '50%', top: '50%',
            transform: 'translate(-50%,-50%)', width: 72, height: 72, borderRadius: '50%', cursor: 'pointer',
            border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(3,5,14,0.55)', color: '#fff', fontSize: 26,
            backdropFilter: 'blur(4px)', opacity: ready ? 1 : 0.4 }}>▶</button>
        ) : null}
      </div>
      <div style={{ width: dims.w * scale, maxWidth: 360, display: 'flex', alignItems: 'center', gap: 10 }}>
        <button style={btnStyle({ padding: '8px 16px' })} onClick={play}>{playing ? 'Pause' : 'Play'}</button>
        <input type="range" min={0} max={Math.max(0.1, D)} step={0.05} value={Math.min(pos, D)}
          onChange={e => { const t = +e.target.value; if (playRef.current) stopLoop(); setPos(t); drawAt(t); tlSync(envRef.current && envRef.current.videoSync, t); }}
          style={{ flex: 1, accentColor: '#3b78ff' }} />
        <span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: 'rgba(255,255,255,0.45)', minWidth: 78, textAlign: 'right' }}>
          {pos.toFixed(1)} / {D.toFixed(1)}s
        </span>
      </div>
    </div>
  );
});

Object.assign(window, { SceneComposition, captureSceneEnv, tlDrawFrame, tlSync, recordTimeline, SceneStage });
