/* ===========================================================================
   iRaven App Store Studio — App Preview (video) engine.
   - Media slots accept IMAGES (dataURL/path strings) or VIDEOS ({kind:'video',
     key} stored as Blobs in IndexedDB).
   - Device frames are captured with an empty screen; screen content (image or
     playing video) is drawn on canvas, clipped to the screen, every frame.
   - Export: WebCodecs+mp4-muxer (.mp4, silent AAC) with per-frame video
     seeking; MediaRecorder realtime fallback.
   =========================================================================== */

/* ---------- easing & keyframe sampling ---------- */
function vidEase(name, u) {
  u = Math.min(1, Math.max(0, u));
  switch (name) {
    case 'linear': return u;
    case 'inOut': return u < 0.5 ? 4*u*u*u : 1 - Math.pow(-2*u + 2, 3) / 2;
    case 'outBack': { const c1 = 1.20158, c3 = c1 + 1; return 1 + c3*Math.pow(u-1,3) + c1*Math.pow(u-1,2); }
    default: return 1 - Math.pow(1 - u, 3); // outCubic
  }
}

const VID_PROPS = ['x', 'y', 's', 'r', 'o'];
const VID_DEFAULTS = { x: 0, y: 0, s: 1, r: 0, o: 1 };

function vidSample(keys, T) {
  const out = Object.assign({}, VID_DEFAULTS);
  if (!keys || !keys.length) return out;
  for (const p of VID_PROPS) {
    let prev = null, next = null;
    for (const k of keys) {
      if (k[p] === undefined) continue;
      if (k.t <= T) prev = k; else { next = k; break; }
    }
    if (!prev && !next) continue;
    if (!prev) { out[p] = next[p]; continue; }
    if (!next) { out[p] = prev[p]; continue; }
    const u = (T - prev.t) / Math.max(0.0001, next.t - prev.t);
    out[p] = prev[p] + (next[p] - prev[p]) * vidEase(next.e, u);
  }
  return out;
}

function vidRoundRect(ctx, x, y, w, h, r) {
  r = Math.min(r, w/2, h/2);
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

/* draws an image, video element, or WebCodecs VideoFrame cover-fitted.
   VideoFrame exposes displayWidth/displayHeight rather than videoWidth /
   naturalWidth, so we fall back through the full list. ctx.drawImage
   accepts VideoFrame natively in modern Chrome/Edge/Firefox/Safari. */
function vidDrawCover(ctx, el, x, y, w, h) {
  if (!el) return;
  const ew = el.videoWidth || el.naturalWidth || el.width || el.displayWidth || el.codedWidth;
  const eh = el.videoHeight || el.naturalHeight || el.height || el.displayHeight || el.codedHeight;
  if (!ew || !eh) return;
  const ir = ew / eh, rr = w / h;
  let dw, dh;
  if (ir > rr) { dh = h; dw = h * ir; } else { dw = w; dh = w / ir; }
  ctx.drawImage(el, x + (w - dw)/2, y + (h - dh)/2, dw, dh);
}

function vidDrawLayer(ctx, layer, v) {
  const r = layer.rect;
  ctx.save();
  ctx.globalAlpha = Math.min(1, Math.max(0, v.o));
  ctx.translate(r.x + r.w/2 + v.x, r.y + r.h/2 + v.y);
  ctx.rotate((v.r || 0) * Math.PI / 180);
  ctx.scale(v.s, v.s);
  ctx.drawImage(layer.img, -r.w/2, -r.h/2, r.w, r.h);
  ctx.restore();
}

function vidPickMime() {
  const cands = ['video/mp4;codecs=avc1.640028', 'video/mp4;codecs=avc1', 'video/mp4',
    'video/webm;codecs=vp9', 'video/webm'];
  for (const m of cands) { try { if (MediaRecorder.isTypeSupported(m)) return m; } catch (e) {} }
  return '';
}

/* ---------- IndexedDB store for dropped video files ---------- */
function vidDb() {
  return new Promise((res, rej) => {
    const rq = indexedDB.open('socc360-media', 1);
    rq.onupgradeneeded = () => rq.result.createObjectStore('media');
    rq.onsuccess = () => res(rq.result);
    rq.onerror = () => rej(rq.error);
  });
}
async function vidMediaPut(key, blob) {
  const db = await vidDb();
  await new Promise((res, rej) => {
    const tx = db.transaction('media', 'readwrite');
    tx.objectStore('media').put(blob, key);
    tx.oncomplete = () => res();
    tx.onerror = () => rej(tx.error);
  });
  /* best-effort backup to the local backend (Docker) */
  if (window.__hasBackend) {
    fetch('/api/media/' + key, { method: 'PUT',
      headers: { 'content-type': blob.type || 'application/octet-stream' }, body: blob }).catch(() => {});
  }
}
async function vidMediaGet(key) {
  const db = await vidDb();
  const local = await new Promise((res, rej) => {
    const rq = db.transaction('media').objectStore('media').get(key);
    rq.onsuccess = () => res(rq.result || null);
    rq.onerror = () => rej(rq.error);
  });
  if (local) return local;
  /* fall back to the backend copy (e.g. fresh browser, persisted volume) */
  if (window.__hasBackend) {
    try {
      const r = await fetch('/api/media/' + key);
      if (r.ok) {
        const blob = await r.blob();
        try {
          await new Promise((res) => {
            const tx = db.transaction('media', 'readwrite');
            tx.objectStore('media').put(blob, key);
            tx.oncomplete = res; tx.onerror = res;
          });
        } catch (e) {}
        return blob;
      }
    } catch (e) {}
  }
  return null;
}
async function vidMediaDel(key) {
  try {
    const db = await vidDb();
    await new Promise((res) => {
      const tx = db.transaction('media', 'readwrite');
      tx.objectStore('media').delete(key);
      tx.oncomplete = res; tx.onerror = res;
    });
  } catch (e) {}
  if (window.__hasBackend) fetch('/api/media/' + key, { method: 'DELETE' }).catch(() => {});
}

/* ---------- exporters ---------- */
/* timer-throttle-proof yield (setTimeout is clamped in background iframes) */
function vidYield() {
  return new Promise(r => {
    const ch = new MessageChannel();
    ch.port1.onmessage = () => r();
    ch.port2.postMessage(0);
  });
}

/* decode a stored audio blob to mono 44.1k PCM, trimmed/padded to D seconds,
   with a 1s fade-out — used as the exported mp4's audio track */
async function vidPickAacConfig() {
  if (typeof AudioEncoder === 'undefined') return null;
  for (const c of [
    { sampleRate: 48000, numberOfChannels: 2, bitrate: 128000 },
    { sampleRate: 44100, numberOfChannels: 2, bitrate: 128000 },
  ]) {
    try {
      const r = await AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', ...c });
      if (r.supported) return { codec: 'mp4a.40.2', ...c };
    } catch (e) {}
  }
  return null;
}

async function vidLoadAudioPCM(key, D, rate) {
  rate = rate || 48000;
  try {
    const blob = await vidMediaGet(key);
    if (!blob) return null;
    const n = Math.round(D * rate);
    const off = new OfflineAudioContext(2, n, rate);
    const buf = await off.decodeAudioData(await blob.arrayBuffer());
    const src = off.createBufferSource();
    src.buffer = buf;
    const g = off.createGain();
    g.gain.setValueAtTime(1, 0);
    g.gain.setValueAtTime(1, Math.max(0, D - 1));
    g.gain.linearRampToValueAtTime(0, D);
    src.connect(g); g.connect(off.destination);
    src.start(0);
    const rendered = await off.startRendering();
    return { l: rendered.getChannelData(0), r: rendered.getChannelData(1) };
  } catch (e) { console.warn('soundtrack decode failed', e); return null; }
}

/* Silent AAC-LC stereo 48 kHz, built from the canonical raw silent frame —
   guarantees an App Store-valid audio track even when the browser has no
   AAC encoder (WebCodecs AAC encode is missing in many Chromium builds). */
const VID_SILENT_AAC_FRAME = new Uint8Array([0x21, 0x10, 0x04, 0x60, 0x8c, 0x1c]);
const VID_AAC_ASC = new Uint8Array([0x11, 0x90]);   // AAC-LC · 48 kHz · stereo
function vidAddSilentAac(muxer, D) {
  const frames = Math.ceil(D * 48000 / 1024);
  for (let i = 0; i < frames; i++) {
    muxer.addAudioChunkRaw(VID_SILENT_AAC_FRAME, 'key',
      Math.round(i * 1024 * 1e6 / 48000), Math.round(1024 * 1e6 / 48000),
      i === 0 ? { decoderConfig: { codec: 'mp4a.40.2', sampleRate: 48000,
        numberOfChannels: 2, description: VID_AAC_ASC } } : undefined);
  }
}

/* WebCodecs + mp4-muxer → real .mp4 (H.264 + AAC soundtrack or silence) */
async function vidEncodeMp4(canvas, drawAt, D, fps, onProgress, prepFrame, pcm, aacCfg) {
  try {
    const ok = await VideoEncoder.isConfigSupported({ codec: 'avc1.640028',
      width: canvas.width, height: canvas.height, bitrate: 12000000, framerate: fps });
    if (!ok.supported) return null;
  } catch (e) { return null; }

  const { Muxer, ArrayBufferTarget } = window.Mp4Muxer;
  const audioOk = !!(aacCfg && pcm);   // real AAC soundtrack vs raw silent track

  const muxer = new Muxer({
    target: new ArrayBufferTarget(),
    video: { codec: 'avc', width: canvas.width, height: canvas.height },
    audio: { codec: 'aac', sampleRate: audioOk ? aacCfg.sampleRate : 48000, numberOfChannels: 2 },
    fastStart: 'in-memory',
  });

  let encErr = null;
  const venc = new VideoEncoder({
    output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
    error: e => { encErr = e; },
  });
  venc.configure({ codec: 'avc1.640028', width: canvas.width, height: canvas.height,
    bitrate: 12000000, framerate: fps });

  const total = Math.round(D * fps);
  for (let f = 0; f < total; f++) {
    if (encErr) throw encErr;
    if (prepFrame) await prepFrame(f / fps);
    drawAt(f / fps);
    const vf = new VideoFrame(canvas, {
      timestamp: Math.round(f * 1e6 / fps), duration: Math.round(1e6 / fps) });
    venc.encode(vf, { keyFrame: f % (fps * 2) === 0 });
    vf.close();
    if (f % 10 === 0) {
      onProgress && onProgress(f / total);
      while (venc.encodeQueueSize > 8) await vidYield();
      await vidYield();
    }
  }
  await venc.flush();

  if (audioOk) {
    const aenc = new AudioEncoder({
      output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
      error: e => { encErr = e; },
    });
    aenc.configure(aacCfg);
    const rate = aacCfg.sampleRate;
    const totalSamples = Math.round(D * rate);
    const step = 1024;
    for (let s = 0; s < totalSamples; s += step) {
      const n = Math.min(step, totalSamples - s);
      const data = new Float32Array(2 * n);   // planar stereo: [L…, R…]
      data.set(pcm.l.subarray(s, s + n), 0); data.set(pcm.r.subarray(s, s + n), n);
      const ad = new AudioData({ format: 'f32-planar', sampleRate: rate, numberOfFrames: n,
        numberOfChannels: 2, timestamp: Math.round(s * 1e6 / rate), data });
      aenc.encode(ad);
      ad.close();
    }
    await aenc.flush();
  } else {
    vidAddSilentAac(muxer, D);
  }

  muxer.finalize();
  return new Blob([muxer.target.buffer], { type: 'video/mp4' });
}

/* Deterministic WebCodecs encode → real .mp4 while live videos still get
   decoded frames presented (we await requestVideoFrameCallback per slot
   rather than seeking, which is what produced black frames historically).

   Constant frame rate guarantee: the loop walks `f = 0…D*fps` exactly,
   draws + encodes ONE frame per slot, and only yields between slots. The
   old realtime variant polled performance.now(), so on slow CPU it would
   miss multiple slots and emit a sparse timestamp stream — ffprobe then
   reported avg_frame_rate < 30 and App Store Connect rejected the upload
   with "frame rate too low". This rewrite is the encoder-side half of the
   fix; the server `/api/export` route runs ffmpeg with `-fps_mode cfr`
   as the upload-side guarantee. */
async function vidEncodeMp4Realtime(canvas, drawAt, D, fps, syncFn, onStatus, pcm, aacCfg) {
  try {
    const ok = await VideoEncoder.isConfigSupported({ codec: 'avc1.640028',
      width: canvas.width, height: canvas.height, bitrate: 12000000, framerate: fps });
    if (!ok.supported) return null;
  } catch (e) { return null; }

  const { Muxer, ArrayBufferTarget } = window.Mp4Muxer;
  const audioOk = !!(aacCfg && pcm);   // real AAC soundtrack vs raw silent track

  const muxer = new Muxer({
    target: new ArrayBufferTarget(),
    video: { codec: 'avc', width: canvas.width, height: canvas.height },
    audio: { codec: 'aac', sampleRate: audioOk ? aacCfg.sampleRate : 48000, numberOfChannels: 2 },
    fastStart: 'in-memory',
  });
  let encErr = null;
  const venc = new VideoEncoder({
    output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
    error: e => { encErr = e; },
  });
  venc.configure({ codec: 'avc1.640028', width: canvas.width, height: canvas.height,
    bitrate: 12000000, framerate: fps });

  /* Total frame slots and per-slot duration. Every slot from 0..total-1
     emits exactly one VideoFrame with timestamp = f * 1e6/fps, so the
     resulting stream is constant frame rate by construction.

     `seekFn` (was the old syncFn) is now awaited PER FRAME. The previous
     implementation called el.currentTime = T and returned immediately,
     so the canvas read the previous decoded frame and we emitted the
     same content for several slots in a row — that's the "frames
     missing / video is choppy / glued together" complaint. The replacement
     vidAwaitSeek below waits for the `seeked` event before drawing, so
     every emitted VideoFrame reflects the timestamp it carries. */
  const total = Math.round(D * fps);
  for (let f = 0; f < total; f++) {
    if (encErr) throw encErr;
    const T = f / fps;
    if (syncFn) await syncFn(T);
    drawAt(T);
    const vf = new VideoFrame(canvas, {
      timestamp: Math.round(f * 1e6 / fps),
      duration: Math.round(1e6 / fps),
    });
    venc.encode(vf, { keyFrame: f % (fps * 2) === 0 });
    vf.close();
    if (f % 15 === 0) onStatus && onStatus(`Recording ${Math.floor(T)}s / ${D}s…`);
    /* Backpressure — if the encoder queue grows the CPU is the bottleneck.
       Wait until the queue drains rather than dropping slots. */
    while (venc.encodeQueueSize > 8) await vidYield();
    await vidYield();
  }
  await venc.flush();

  if (audioOk) {
    const aenc = new AudioEncoder({
      output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
      error: e => { encErr = e; },
    });
    aenc.configure(aacCfg);
    const rate = aacCfg.sampleRate;
    const totalSamples = Math.round(D * rate);
    const step = 1024;
    for (let s = 0; s < totalSamples; s += step) {
      const n = Math.min(step, totalSamples - s);
      const data = new Float32Array(2 * n);   // planar stereo: [L…, R…]
      data.set(pcm.l.subarray(s, s + n), 0); data.set(pcm.r.subarray(s, s + n), n);
      const ad = new AudioData({ format: 'f32-planar', sampleRate: rate, numberOfFrames: n,
        numberOfChannels: 2, timestamp: Math.round(s * 1e6 / rate), data });
      aenc.encode(ad);
      ad.close();
    }
    await aenc.flush();
  } else {
    vidAddSilentAac(muxer, D);
  }
  muxer.finalize();
  return new Blob([muxer.target.buffer], { type: 'video/mp4' });
}

/* decode a stored audio blob into a realtime MediaStream (for MediaRecorder
   exports on browsers without a WebCodecs AAC encoder — e.g. Chrome). Plays the
   soundtrack through a MediaStreamDestination, started in sync with recording,
   with a 1s fade-out at D. */
async function vidLoadAudioStream(key, D) {
  try {
    const blob = await vidMediaGet(key);
    if (!blob) return null;
    const AC = window.AudioContext || window.webkitAudioContext;
    const ctx = new AC();
    const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
    const dest = ctx.createMediaStreamDestination();
    const src = ctx.createBufferSource(); src.buffer = buf; src.loop = buf.duration < D;
    const g = ctx.createGain();
    g.gain.setValueAtTime(1, 0);
    g.gain.setValueAtTime(1, Math.max(0.01, D - 1));
    g.gain.linearRampToValueAtTime(0, D);
    src.connect(g); g.connect(dest);
    return {
      stream: dest.stream,
      start: () => { try { ctx.resume(); } catch (e) {} try { src.start(0); } catch (e) {} },
      stop: () => { try { src.stop(); } catch (e) {} setTimeout(() => { try { ctx.close(); } catch (e) {} }, 200); },
    };
  } catch (e) { console.warn('soundtrack stream failed', e); return null; }
}

/* MediaRecorder realtime fallback (.webm on most browsers).
   If `audio` (from vidLoadAudioStream) is given, its track is muxed in so the
   soundtrack survives even without a WebCodecs AAC encoder. */
async function vidRecordRealtime(canvas, drawAt, D, onStatus, syncFn, audio) {
  let mime = vidPickMime();
  if (audio && audio.stream) {
    /* prefer a mime that explicitly carries an audio (opus) track */
    const a = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9', 'video/webm'];
    mime = a.find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || mime;
  }
  if (!mime) return null;
  drawAt(0);
  const stream = canvas.captureStream(30);
  if (audio && audio.stream) { try { audio.stream.getAudioTracks().forEach(t => stream.addTrack(t)); } catch (e) {} }
  const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 12000000, audioBitsPerSecond: 192000 });
  const chunks = [];
  rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
  const stopped = new Promise(res => { rec.onstop = res; });
  rec.start(250);
  if (audio && audio.start) audio.start();
  {
    const t0 = performance.now();
    let lastShown = -1;
    while (true) {
      const T = (performance.now() - t0) / 1000;
      if (T >= D) { drawAt(D - 0.01); break; }
      if (syncFn) syncFn(T);
      drawAt(T);
      const s = Math.floor(T);
      if (s !== lastShown) { lastShown = s; onStatus && onStatus(`Recording ${s}s / ${D}s…`); }
      await vidYield();
    }
  }
  rec.stop();
  if (audio && audio.stop) audio.stop();
  await stopped;
  const isMp4 = mime.indexOf('mp4') !== -1;
  return { blob: new Blob(chunks, { type: mime }), ext: isMp4 ? 'mp4' : 'webm' };
}

/* ---------- media time sync ---------- */
/* media time sync (preview + realtime recording) */
function vidSyncPreview(env, T) {
  for (const s of env.videoSync || []) {
    const vt = Math.max(0, T - s.start) % s.duration;
    if (T < s.start) {
      /* hold the first frame until this scene's cue — don't let it run early */
      if (!s.el.paused) { try { s.el.pause(); } catch (e) {} }
      if (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 - vt) > 0.4) { try { s.el.currentTime = vt; } catch (e) {} }
  }
}
function vidPauseMedia(env) {
  for (const s of (env && env.videoSync) || []) { try { s.el.pause(); } catch (e) {} }
}
async function vidSeekExport(env, T) {
  for (const s of env.videoSync || []) {
    const vt = Math.max(0, T - s.start) % s.duration;
    if (Math.abs(s.el.currentTime - vt) < 0.02) 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 = vt; } catch (e) { fin(); }
      setTimeout(fin, 500);
    });
  }
}

/* Warm the decoder so the first drawn frames aren't black: seek to 0, play
   until a real frame is presented, then pause back at 0. */
async function vidPrimeMedia(env) {
  for (const s of (env && env.videoSync) || []) {
    const el = s.el;
    try {
      await new Promise(res => {
        let done = false;
        const fin = () => { if (done) return; done = true; el.removeEventListener('seeked', fin); res(); };
        el.addEventListener('seeked', fin);
        try { el.currentTime = 0; } catch (e) { fin(); }
        setTimeout(fin, 800);
      });
      await el.play().catch(() => {});
      if (el.requestVideoFrameCallback) {
        await new Promise(res => { let d = false; const f = () => { if (!d) { d = true; res(); } };
          el.requestVideoFrameCallback(f); setTimeout(f, 800); });
      } else {
        await new Promise(r => setTimeout(r, 150));
      }
      el.pause();
      el.currentTime = 0;
    } catch (e) {}
  }
}
function vidDisposeEnv(env) {
  if (!env) return;
  vidPauseMedia(env);
  for (const u of env._urls || []) { try { URL.revokeObjectURL(u); } catch (e) {} }
}

/* Can this browser actually DECODE the video (not just read its metadata)?
   iPhone screen recordings are often HEVC/H.265, which many browsers cannot
   play — the element loads metadata (videoWidth set) but never produces a
   frame, resulting in a black screen. readyState >= 2 means a real frame. */
function vidProbeVideo(blob) {
  return new Promise(res => {
    const el = document.createElement('video');
    el.muted = true; el.playsInline = true; el.preload = 'auto';
    const u = URL.createObjectURL(blob);
    let done = false;
    const fin = (ok, reason) => {
      if (done) return; done = true;
      const dur = isFinite(el.duration) && el.duration > 0 ? el.duration : null;
      try { URL.revokeObjectURL(u); } catch (e) {}
      res({ ok, reason, dur });
    };
    el.onloadeddata = () => fin(el.videoWidth > 0 && el.readyState >= 2, 'no decodable frames');
    el.onerror = () => fin(false, 'decode error');
    setTimeout(() => fin(el.readyState >= 2 && el.videoWidth > 0, 'decode timeout'), 6000);
    el.src = u;
  });
}
const VID_CODEC_MSG = '⚠ Video can\u2019t be decoded by this browser (likely HEVC/H.265). Convert to H.264 .mp4 — on iPhone: Settings → Camera → Formats → Most Compatible.';

/* ---------- base layout (DOM) — also used for thumbnails ---------- */
const VID_LAYOUTS = {
  rise:      { devW: 0.74, devTop: 0.30, titleTop: 0.062, align: 'center' },
  reveal:    { devW: 0.70, devTop: 0.34, titleTop: 0.062, align: 'center' },
  punch:     { devW: 0.72, devTop: 0.31, titleTop: 0.062, align: 'center' },
  sweep:     { devW: 0.78, devTop: 0.32, titleTop: 0.060, align: 'left' },
  drift:     { devW: 0.80, devTop: 0.33, titleTop: 0.060, align: 'left' },
  tilt:      { devW: 0.72, devTop: 0.32, titleTop: 0.062, align: 'center' },
  orbit:     { devW: 0.84, devTop: 0.31, titleTop: 0.058, align: 'left' },
  zoomline:  { devW: 0.76, devTop: 0.30, titleTop: 0.062, align: 'center' },
  risefan:   { devW: 0.54, devTop: 0.36, titleTop: 0.055, align: 'center', duo: true },
  showcase:  { devW: 0.64, devTop: 0.27, titleTop: 0.062, align: 'center' },
  duo:       { devW: 0.52, devTop: 0.36, titleTop: 0.055, align: 'center', duo: true },
  companion: { devW: 0.56, devTop: 0.33, titleTop: 0.058, align: 'center' },
  spotlight: { devW: 0.70, devTop: 0.33, titleTop: 0.10,  align: 'center' },
  pulse:     { devW: 0,    devTop: 0,    titleTop: 0.70,  align: 'left' },
};

function VidMediaBadge({ media, dims }) {
  if (!media || (typeof media === 'string' && !window.isVideoPath(media))) return null;
  return (
    <div style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)',
      display: 'flex', alignItems: 'center', gap: dims.w * 0.012,
      padding: `${dims.w*0.012}px ${dims.w*0.025}px`, borderRadius: 999,
      background: 'rgba(0,0,0,0.6)', border: '1px solid rgba(255,255,255,0.2)',
      fontFamily: "'JetBrains Mono', monospace", fontSize: dims.w * 0.026,
      color: 'rgba(255,255,255,0.75)', whiteSpace: 'nowrap', maxWidth: '80%', overflow: 'hidden' }}>
      ▶ video
    </div>
  );
}

function VideoComposition({ video, mode }) {
  const vd = window.VIDEO_DEVICES[video.device] || window.VIDEO_DEVICES.iphone;
  const dims = vd.canvas;
  const spec = window.DEVICES[video.device] || window.DEVICES.iphone;
  const accentColor = window.resolveAccent(video.accent);
  const isCapture = mode === 'capture';
  const t = video.template;
  const slots = video.images || [];
  const m0 = slots[0] || null;
  const m1 = slots[1] || null;
  const img0 = (typeof m0 === 'string' && !window.isVideoPath(m0)) ? m0 : null;
  const img1 = (typeof m1 === 'string' && !window.isVideoPath(m1)) ? m1 : null;

  const layout = VID_LAYOUTS[t] || VID_LAYOUTS.rise;
  const devW = dims.w * layout.devW;
  const pad = devW * 0.18; // room for the drop shadow in the captured layer

  const titleStyle = layout.align === 'left'
    ? { position: 'absolute', left: dims.w * 0.08, top: dims.h * layout.titleTop, width: dims.w * 0.84 }
    : { position: 'absolute', left: dims.w * 0.07, top: dims.h * layout.titleTop, width: dims.w * 0.86,
        display: 'flex', justifyContent: 'center' };

  const deviceNode = (name, leftPx, topPx, media, imgStr, spec2, w2) => {
    const dspec = spec2 || spec;
    const dw = w2 || devW;
    const dpad = dw * 0.18;
    return (
      <div data-layer={name} style={{ position: 'absolute', left: leftPx - dpad, top: topPx - dpad, padding: dpad }}>
        <div style={{ position: 'relative' }}>
          <DeviceFrame device={dspec} width={dw}
            image={isCapture ? null : imgStr} emptyScreen={isCapture || (!imgStr && !!media)} />
          {!isCapture ? <VidMediaBadge media={media} dims={dims} /> : null}
        </div>
      </div>
    );
  };

  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={video.bg} dims={dims} />
        <Atmosphere dims={dims} />
      </div>

      {t === 'pulse' ? (
        !isCapture ? (
          <>
            {img0 ? (
              <img src={resolveAsset(img0)} crossOrigin="anonymous" style={{ position: 'absolute', inset: 0,
                width: '100%', height: '100%', objectFit: 'cover' }} />
            ) : (
              <div style={{ position: 'absolute', inset: 0,
                background: `repeating-linear-gradient(135deg, #0e1830 0 ${dims.w*0.03}px, #0a1326 ${dims.w*0.03}px ${dims.w*0.06}px)` }}>
                <VidMediaBadge media={m0} dims={dims} />
              </div>
            )}
            <div style={{ position: 'absolute', inset: 0,
              background: 'linear-gradient(180deg, rgba(3,5,14,0.15) 38%, rgba(3,5,14,0.94) 82%)' }} />
          </>
        ) : null
      ) : t === 'companion' ? (
        <>
          {deviceNode('device', dims.w * 0.045, dims.h * layout.devTop, m0, img0)}
          {deviceNode('watch', dims.w * 0.60, dims.h * (layout.devTop + 0.20), m1, img1,
            window.DEVICES.watch, dims.w * 0.37)}
        </>
      ) : layout.duo ? (
        <>
          {deviceNode('device', dims.w * 0.02, dims.h * 0.40, m0, img0)}
          {deviceNode('device2', dims.w - devW - dims.w * 0.02, dims.h * 0.32, m1, img1)}
        </>
      ) : (
        deviceNode('device', (dims.w - devW) / 2, dims.h * layout.devTop, m0, img0)
      )}

      <div data-layer="title" style={titleStyle}>
        <Headline slide={video} dims={dims} accentColor={accentColor}
          align={layout.align} baseRatio={0.082} maxWidth={0.86} />
      </div>

      <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>
    </div>
  );
}

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

/* fonts as data-URI css for html-to-image. In the standalone bundle the Google
   stylesheet is inlined same-origin, so we can read @font-face rules directly
   (works offline); otherwise fetch from Google Fonts. */
async function vidCssFromDocumentFonts() {
  let out = '';
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch (e) { continue; }
    if (!rules) continue;
    for (const r of rules) {
      if (r.type === CSSRule.FONT_FACE_RULE) out += r.cssText + '\n';
    }
  }
  if (!out) return '';
  const urls = [...new Set([...out.matchAll(/url\("?([^")]+)"?\)/g)].map(m => m[1]))]
    .filter(u => u.indexOf('data:') !== 0);
  for (const u of urls) {
    try {
      const blob = await (await fetch(u)).blob();
      const dataUrl = await new Promise(r => {
        const f = new FileReader(); f.onload = () => r(f.result); f.readAsDataURL(blob);
      });
      out = out.split(u).join(dataUrl);
    } catch (e) {}
  }
  return out;
}

async function getFontEmbedCss() {
  if (window.__socc360FontCss !== undefined) return window.__socc360FontCss;
  try {
    const local = await vidCssFromDocumentFonts();
    if (local) { window.__socc360FontCss = local; return local; }
  } catch (e) {}
  try {
    const cssUrl = 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@700&family=Inter:wght@400;600&family=JetBrains+Mono:wght@400&display=swap';
    let css = await (await fetch(cssUrl)).text();
    const urls = [...new Set([...css.matchAll(/url\(([^)]+)\)/g)].map(m => m[1]))];
    for (const u of urls) {
      const blob = await (await fetch(u)).blob();
      const dataUrl = await new Promise(r => {
        const f = new FileReader(); f.onload = () => r(f.result); f.readAsDataURL(blob);
      });
      css = css.split(u).join(dataUrl);
    }
    window.__socc360FontCss = css;
    return css;
  } catch (e) {
    console.warn('font embed failed', e);
    window.__socc360FontCss = '';
    return '';
  }
}

async function captureVideoEnv(video) {
  const vd = window.VIDEO_DEVICES[video.device] || window.VIDEO_DEVICES.iphone;
  const dims = vd.canvas;
  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(<VideoComposition video={video} mode="capture" />);
  for (let i = 0; i < 800 && !host.firstChild; i++) await 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 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: VID_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 media (slot-aligned): strings → Image, {kind:'video'} → <video> from IDB */
  const media = [];
  const _urls = [];
  const mediaErrors = [];
  for (const m of (video.images || [])) {
    if (!m) { media.push(null); continue; }
    if (typeof m === 'string' && window.isVideoPath(m)) {
      const el = document.createElement('video');
      el.muted = true; el.playsInline = true; el.preload = 'auto'; el.loop = false;
      el.crossOrigin = 'anonymous';
      el.src = resolveAsset(m);
      await new Promise(res => { el.onloadeddata = res; el.onerror = res; setTimeout(res, 6000); });
      const ok = !!(el.videoWidth && el.readyState >= 2);
      if (!ok) mediaErrors.push(m);
      media.push(ok ? { kind: 'video', el, duration: Math.max(0.5, isFinite(el.duration) ? el.duration : 1) } : null);
    } else if (typeof m === 'string') {
      const im = new Image(); im.crossOrigin = 'anonymous'; im.src = resolveAsset(m);
      try { await im.decode(); } catch (e) {}
      media.push(im.naturalWidth ? { kind: 'image', el: im } : null);
    } else if (m.kind === 'video') {
      try {
        const blob = await vidMediaGet(m.key);
        if (!blob) { media.push(null); continue; }
        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, 4000);
        });
        /* MediaRecorder blobs report duration=Infinity and refuse seeks until
           forced to compute cues — standard workaround: */
        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 decodable = !!(el.videoWidth && el.readyState >= 2);
        if (!decodable) mediaErrors.push(m.name || m.key);
        media.push(decodable ? { kind: 'video', el, duration: Math.max(0.5, isFinite(el.duration) ? el.duration : 1) } : null);
      } catch (e) { media.push(null); }
    } else { media.push(null); }
  }

  const accentColor = window.resolveAccent(video.accent);
  const dl = layers.device;
  return {
    dims, layers, media, accentColor, _urls, mediaErrors,
    frameSpec: window.DEVICES[video.device] || window.DEVICES.iphone,
    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.6 },
  };
}

/* screen rect (relative to layer center) from frame geometry */
function vidScreenRect(env, layerName, specOverride) {
  const dl = env.layers[layerName];
  const f = (specOverride || env.frameSpec).frame;
  const pad = (dl.rect.w - (dl.rect.w / 1.36)) / 2; // wrapper padding was devW*0.18
  const devW = dl.rect.w - pad * 2;
  const devH = dl.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,
  };
}

/* ---------- timelines ---------- */
function buildVideoOps(video, env) {
  const D = video.duration || 15;
  const dims = env.dims;
  const hasOutro = video.outro !== false;
  const tail = hasOutro ? D - 2.6 : D;
  const t = video.template;
  const ops = [];
  env.videoSync = [];
  const syncIf = (m, start) => {
    if (m && m.kind === 'video') env.videoSync.push({ el: m.el, start, duration: m.duration });
  };

  ops.push({ kind: 'image', name: 'bg', keys: [{ t: 0, o: 0, s: 1.05 }, { t: 0.8, o: 1 }, { t: D, s: 1.0 }] });

  const titleIn = (delay = 0.25) => [
    { t: delay, o: 0, y: 46 }, { t: delay + 0.85, o: 1, y: 0, e: 'out' }, { t: tail, y: -10, e: 'linear' }];

  if (t === 'pulse') {
    ops.push({ kind: 'shot', keys: [{ t: 0, o: 0, s: 1.0 }, { t: 0.9, o: 1 }, { t: D, s: 1.14, e: 'linear' }] });
    ops.push({ kind: 'scrim', keys: [{ t: 0, o: 0 }, { t: 1.0, o: 1 }] });
    ops.push({ kind: 'image', name: 'title', keys: [
      { t: 0.5, o: 0, y: 44 }, { t: 1.5, o: 1, y: 0, e: 'out' }, { t: tail, y: -8, e: 'linear' }] });
    syncIf(env.media[0], 0);

  } else if (t === 'reveal') {
    const sr = vidScreenRect(env, 'device');
    const s0 = Math.max(dims.w / sr.sw, dims.h / sr.sh) * 1.02;
    const y0 = dims.h * 0.5 - env.deviceCenter.y;
    const devKeys = [
      { t: 0, o: 1, s: s0, y: y0 }, { t: 2.3, s: 1, y: 0, e: 'inOut' }, { t: tail, s: 1.02, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 1.8, o: 0 }, { t: 2.8, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 0 });
    ops.push({ kind: 'image', name: 'title', keys: [
      { t: 2.0, o: 0, y: 30 }, { t: 2.8, o: 1, y: 0, e: 'out' }] });
    syncIf(env.media[0], 0);

  } else if (t === 'sweep') {
    const devKeys = [
      { t: 0.3, o: 0, x: dims.w * 0.55, r: 7 }, { t: 1.9, o: 1, x: 0, r: -4, e: 'out' },
      { t: tail, x: -dims.w * 0.012, r: -4, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 2.2, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.6 });
    ops.push({ kind: 'image', name: 'title', keys: [
      { t: 0.4, o: 0, x: -70 }, { t: 1.4, o: 1, x: 0, e: 'out' }] });
    syncIf(env.media[0], 1.6);

  } else if (t === 'orbit') {
    const devKeys = [
      { t: 0.4, o: 0, s: 1.16, r: -13, y: 70 }, { t: 2.1, o: 1, s: 1.02, r: -7, y: 0, e: 'out' },
      { t: tail, r: -5, s: 1.06, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.7, o: 0 }, { t: 2.2, o: 0.55 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.8 });
    ops.push({ kind: 'image', name: 'title', keys: [
      { t: 0.2, o: 0, x: -56 }, { t: 1.2, o: 1, x: 0, e: 'out' }] });
    syncIf(env.media[0], 1.8);

  } else if (t === 'showcase') {
    const from = 1.5;
    const list = env.media.filter(Boolean);
    const n = Math.max(1, list.length);
    const cyc = (tail - from) / n;
    const devKeys = [{ t: 0.4, o: 0, y: dims.h * 0.22 }, { t: 1.5, o: 1, y: 0, e: 'out' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 2, o: 0.45 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, multi: true, from, until: tail });
    ops.push({ kind: 'image', name: 'title', keys: titleIn() });
    list.forEach((m, i) => syncIf(m, from + i * cyc));

  } else if (t === 'duo') {
    const keysA = [
      { t: 0.4, o: 0, y: dims.h * 0.26, r: -6 }, { t: 1.7, o: 1, y: 0, r: -6, e: 'outBack' },
      { t: tail, y: -dims.h * 0.012, r: -6, e: 'linear' }];
    const keysB = [
      { t: 0.7, o: 0, y: dims.h * 0.28, r: 5 }, { t: 2.0, o: 1, y: 0, r: 5, e: 'outBack' },
      { t: tail, y: -dims.h * 0.018, r: 5, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.9, o: 0 }, { t: 2.2, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: keysA });
    ops.push({ kind: 'screens', layer: 'device', keys: keysA, slot: 0, start: 1.7 });
    ops.push({ kind: 'image', name: 'device2', keys: keysB });
    ops.push({ kind: 'screens', layer: 'device2', keys: keysB, slot: 1, start: 2.0 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn() });
    syncIf(env.media[0], 1.7);
    syncIf(env.media[1], 2.0);

  } else if (t === 'companion') {
    const keysA = [
      { t: 0.4, o: 0, y: dims.h * 0.24, r: -4 }, { t: 1.7, o: 1, y: 0, r: -4, e: 'outBack' },
      { t: tail, y: -dims.h * 0.012, r: -4, e: 'linear' }];
    const keysB = [
      { t: 0.8, o: 0, y: dims.h * 0.26, r: 6 }, { t: 2.1, o: 1, y: 0, r: 6, e: 'outBack' },
      { t: tail, y: -dims.h * 0.02, r: 6, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.9, o: 0 }, { t: 2.2, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: keysA });
    ops.push({ kind: 'screens', layer: 'device', keys: keysA, slot: 0, start: 1.7 });
    ops.push({ kind: 'image', name: 'watch', keys: keysB });
    ops.push({ kind: 'screens', layer: 'watch', keys: keysB, slot: 1, start: 2.1, spec: 'watch' });
    ops.push({ kind: 'image', name: 'title', keys: titleIn() });
    syncIf(env.media[0], 1.7);
    syncIf(env.media[1], 2.1);

  } else if (t === 'punch') {
    const devKeys = [
      { t: 0.4, o: 0, s: 0.55 }, { t: 1.2, o: 1, s: 1.05, e: 'outBack' },
      { t: 1.8, s: 1.0, e: 'inOut' }, { t: tail, s: 1.03, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 1.3, o: 0.7 }, { t: 2.6, o: 0.35 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.2 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn(0.2) });
    syncIf(env.media[0], 1.2);

  } else if (t === 'drift') {
    const devKeys = [
      { t: 0.4, o: 0, x: dims.w * 0.12, y: dims.h * 0.06, r: 4 },
      { t: 2.0, o: 1, e: 'out' },
      { t: tail, x: -dims.w * 0.06, y: -dims.h * 0.02, r: -3, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 2.2, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.6 });
    ops.push({ kind: 'image', name: 'title', keys: [
      { t: 0.3, o: 0, x: -60 }, { t: 1.3, o: 1, x: 0, e: 'out' }] });
    syncIf(env.media[0], 1.6);

  } else if (t === 'tilt') {
    const mid = (1.8 + tail) / 2;
    const devKeys = [
      { t: 0.4, o: 0, y: dims.h * 0.2, r: -12 }, { t: 1.8, o: 1, y: 0, r: -6, e: 'outBack' },
      { t: mid, r: 5, e: 'inOut' }, { t: tail, r: -4, e: 'inOut' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 2.1, o: 0.55 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.5 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn(0.25) });
    syncIf(env.media[0], 1.5);

  } else if (t === 'zoomline') {
    const devKeys = [
      { t: 0.4, o: 0, s: 0.92 }, { t: 1.6, o: 1, s: 0.96, e: 'out' }, { t: tail, s: 1.1, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [
      { t: 0.6, o: 0, x: -dims.w * 0.2 }, { t: 2.0, o: 0.5, x: 0, e: 'inOut' },
      { t: tail, o: 0.35, x: dims.w * 0.15, e: 'inOut' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.2 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn(0.25) });
    syncIf(env.media[0], 1.2);

  } else if (t === 'risefan') {
    const keysA = [
      { t: 0.4, o: 0, y: dims.h * 0.30, r: -9 }, { t: 1.6, o: 1, y: 0, r: -7, e: 'outBack' },
      { t: tail, y: -dims.h * 0.01, r: -7, e: 'linear' }];
    const keysB = [
      { t: 0.8, o: 0, y: dims.h * 0.34, r: 8 }, { t: 2.1, o: 1, y: 0, r: 6, e: 'outBack' },
      { t: tail, y: -dims.h * 0.02, r: 6, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.9, o: 0 }, { t: 2.2, o: 0.5 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: keysA });
    ops.push({ kind: 'screens', layer: 'device', keys: keysA, slot: 0, start: 1.6 });
    ops.push({ kind: 'image', name: 'device2', keys: keysB });
    ops.push({ kind: 'screens', layer: 'device2', keys: keysB, slot: 1, start: 2.1 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn() });
    syncIf(env.media[0], 1.6);
    syncIf(env.media[1], 2.1);

  } else if (t === 'spotlight') {
    const devKeys = [
      { t: 0.6, o: 0, s: 1.08 }, { t: 2.2, o: 1, s: 1, e: 'out' }, { t: tail, s: 1.03, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [
      { t: 0.4, o: 0, x: -dims.w * 0.45 }, { t: 2.4, o: 0.6, x: 0, e: 'inOut' },
      { t: tail, o: 0.35, x: dims.w * 0.18, e: 'inOut' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 2.0 });
    ops.push({ kind: 'image', name: 'title', keys: [{ t: 0.3, o: 0 }, { t: 1.6, o: 1 }] });
    syncIf(env.media[0], 2.0);

  } else { // rise
    const devKeys = [
      { t: 0.5, o: 0, y: dims.h * 0.3, s: 0.97 }, { t: 1.8, o: 1, y: 0, s: 1, e: 'outBack' },
      { t: tail, y: -dims.h * 0.015, s: 1.02, e: 'linear' }];
    ops.push({ kind: 'glow', keys: [{ t: 0.8, o: 0 }, { t: 2, o: 0.55 }, { t: tail, o: 0.3, e: 'linear' }] });
    ops.push({ kind: 'image', name: 'device', keys: devKeys });
    ops.push({ kind: 'screens', layer: 'device', keys: devKeys, slot: 0, start: 1.6 });
    ops.push({ kind: 'image', name: 'title', keys: titleIn() });
    syncIf(env.media[0], 1.6);
  }

  /* pacing control: speed up / slow down the choreography (outro untouched) */
  const speed = video.speed || 1;
  if (speed !== 1) {
    for (const op of ops) for (const k of op.keys || []) {
      if (k.t < tail) k.t = +Math.min(tail, k.t / speed).toFixed(3);
    }
    for (const op of ops) {
      if (op.from !== undefined) op.from = +Math.min(tail, op.from / speed).toFixed(3);
      if (op.start !== undefined) op.start = +Math.min(tail, op.start / speed).toFixed(3);
    }
    for (const sy of env.videoSync) sy.start = +Math.min(tail, sy.start / speed).toFixed(3);
  }

  if (hasOutro && env.layers.outro) {
    ops.push({ kind: 'dim', keys: [{ t: tail, o: 0 }, { t: tail + 0.7, o: 0.8 }] });
    ops.push({ kind: 'image', name: 'outro', keys: [
      { t: tail + 0.25, o: 0, y: 24, s: 0.94 }, { t: tail + 0.95, o: 1, y: 0, s: 1, e: 'out' },
      { t: D, s: 1.02, e: 'linear' }] });
  }
  return ops;
}

/* ---------- frame renderer ---------- */
function vidDrawPlaceholderScreen(ctx, sr) {
  ctx.fillStyle = '#0a1326';
  ctx.fillRect(sr.sx, sr.sy, sr.sw, sr.sh);
  ctx.strokeStyle = 'rgba(255,255,255,0.05)';
  ctx.lineWidth = sr.sw * 0.025;
  for (let x = -sr.sh; x < sr.sw; x += sr.sw * 0.14) {
    ctx.beginPath();
    ctx.moveTo(sr.sx + x, sr.sy + sr.sh);
    ctx.lineTo(sr.sx + x + sr.sh, sr.sy);
    ctx.stroke();
  }
  ctx.fillStyle = 'rgba(255,255,255,0.35)';
  ctx.font = `${Math.round(sr.sw * 0.045)}px "JetBrains Mono", monospace`;
  ctx.textAlign = 'center';
  ctx.fillText('DROP SCREENSHOT / VIDEO', sr.sx + sr.sw / 2, sr.sy + sr.sh / 2);
}

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

  for (const op of ops) {
    const v = vidSample(op.keys, T);
    if (v.o <= 0.002) continue;

    if (op.kind === 'image') {
      const layer = env.layers[op.name];
      if (layer) vidDrawLayer(ctx, layer, v);

    } else if (op.kind === 'glow') {
      const cx = env.deviceCenter.x + v.x, cy = env.deviceCenter.y + v.y;
      const rad = dims.w * 0.75 * v.s;
      const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
      g.addColorStop(0, env.accentColor + '66');
      g.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.save(); ctx.globalAlpha = v.o; ctx.fillStyle = g;
      ctx.fillRect(0, 0, dims.w, dims.h); ctx.restore();

    } else if (op.kind === 'dim') {
      ctx.save(); ctx.globalAlpha = v.o; ctx.fillStyle = '#03050e';
      ctx.fillRect(0, 0, dims.w, dims.h); ctx.restore();

    } else if (op.kind === 'scrim') {
      const g = ctx.createLinearGradient(0, 0, 0, dims.h);
      g.addColorStop(0.38, 'rgba(3,5,14,0.15)');
      g.addColorStop(0.82, 'rgba(3,5,14,0.94)');
      ctx.save(); ctx.globalAlpha = v.o; ctx.fillStyle = g;
      ctx.fillRect(0, 0, dims.w, dims.h); ctx.restore();

    } else if (op.kind === 'shot') {
      const m = env.media[0];
      ctx.save(); ctx.globalAlpha = v.o;
      ctx.translate(dims.w/2, dims.h/2); ctx.scale(v.s, v.s); ctx.translate(-dims.w/2, -dims.h/2);
      if (m) vidDrawCover(ctx, m.el, 0, 0, dims.w, dims.h);
      else { ctx.fillStyle = '#0a1326'; ctx.fillRect(0, 0, dims.w, dims.h); }
      ctx.restore();

    } else if (op.kind === 'screens') {
      const dl = env.layers[op.layer];
      if (!dl) continue;
      const sr = vidScreenRect(env, op.layer, op.spec ? window.DEVICES[op.spec] : null);
      ctx.save();
      ctx.globalAlpha = Math.min(1, Math.max(0, v.o));
      ctx.translate(dl.rect.x + dl.rect.w/2 + v.x, dl.rect.y + dl.rect.h/2 + v.y);
      ctx.rotate((v.r || 0) * Math.PI / 180);
      ctx.scale(v.s, v.s);
      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 (op.multi) {
        const list = env.media.filter(Boolean);
        const n = Math.max(1, list.length);
        const cyc = (op.until - op.from) / n;
        let idx = 0, trans = 0;
        if (T > op.from) {
          const p = Math.min(n - 0.0001, (T - op.from) / cyc);
          idx = Math.floor(p);
          const local = p - idx;
          const trWin = Math.min(0.5, cyc * 0.25) / cyc;
          if (idx < n - 1 && local > 1 - trWin) trans = vidEase('inOut', (local - (1 - trWin)) / trWin);
        }
        const cur = list[idx], nxt = list[idx + 1];
        if (!cur) {
          vidDrawPlaceholderScreen(ctx, sr);
        } else {
          ctx.save(); ctx.translate(-trans * sr.sw, 0); vidDrawCover(ctx, cur.el, sr.sx, sr.sy, sr.sw, sr.sh); ctx.restore();
          if (nxt && trans > 0) {
            ctx.save(); ctx.translate((1 - trans) * sr.sw, 0); vidDrawCover(ctx, nxt.el, sr.sx, sr.sy, sr.sw, sr.sh); ctx.restore();
          }
        }
      } else {
        const m = env.media[op.slot || 0];
        if (m) vidDrawCover(ctx, m.el, sr.sx, sr.sy, sr.sw, sr.sh);
        else vidDrawPlaceholderScreen(ctx, sr);
      }

      if (sr.island) {
        const iw = sr.sw * 0.34, ih = sr.sw * 0.092;
        ctx.fillStyle = '#000';
        vidRoundRect(ctx, -iw / 2, sr.sy + sr.sh * 0.018, iw, ih, ih / 2);
        ctx.fill();
      }
      ctx.restore();
    }
  }
}

/* ---------- stage component (canvas + preview controls + recorder) ---------- */
const VideoStage = forwardRef(function VideoStage({ video, idx, scale, onStatus }, ref) {
  const canvasRef = useRef(null);
  const envRef = useRef(null);
  const opsRef = useRef(null);
  const rafRef = useRef(0);
  const playRef = useRef(null);
  const audioRef = useRef(null);
  const busyRef = useRef(false);
  const [ready, setReady] = useState(false);
  const [playing, setPlaying] = useState(false);
  const sig = JSON.stringify([video, idx]);

  const dims = (window.VIDEO_DEVICES[video.device] || window.VIDEO_DEVICES.iphone).canvas;

  const drawAt = (T) => {
    const c = canvasRef.current;
    if (!c || !envRef.current) return;
    vidDrawFrame(c.getContext('2d'), T, envRef.current, opsRef.current, video);
  };

  const stopLoop = () => {
    cancelAnimationFrame(rafRef.current);
    playRef.current = null;
    setPlaying(false);
    vidPauseMedia(envRef.current);
    if (audioRef.current) { try { audioRef.current.pause(); } catch (e) {} }
  };

  const prepare = async () => {
    if (busyRef.current) return false;
    busyRef.current = true;
    setReady(false);
    onStatus && onStatus('Preparing preview…');
    try {
      vidDisposeEnv(envRef.current);
      if (audioRef.current) {
        try { audioRef.current.pause(); URL.revokeObjectURL(audioRef.current.src); } catch (e) {}
        audioRef.current = null;
      }
      const env = await captureVideoEnv(video);
      envRef.current = env;
      if (video.audio && video.audio.key) {
        try {
          const ab = await vidMediaGet(video.audio.key);
          if (ab) {
            const a = document.createElement('audio');
            a.src = URL.createObjectURL(ab);
            a.preload = 'auto';
            audioRef.current = a;
          }
        } catch (e) {}
      }
      opsRef.current = buildVideoOps(video, env);
      const c = canvasRef.current;
      c.width = env.dims.w; c.height = env.dims.h;
      drawAt(Math.min(2.4, video.duration || 15));
      setReady(true);
      if (env.mediaErrors && env.mediaErrors.length) {
        onStatus && onStatus(VID_CODEC_MSG);
        setTimeout(() => onStatus && onStatus(null), 9000);
      } else {
        onStatus && onStatus(null);
      }
      busyRef.current = false;
      return true;
    } catch (e) {
      console.error(e);
      onStatus && onStatus('Preview failed');
      busyRef.current = false;
      return false;
    }
  };

  useEffect(() => {
    stopLoop();
    const h = setTimeout(prepare, 450);
    return () => clearTimeout(h);
  }, [sig]);

  useEffect(() => () => {
    stopLoop();
    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 = video.duration || 15;
    const token = { start: performance.now() };
    playRef.current = token;
    setPlaying(true);
    /* paced by MessageChannel (vidYield) — unlike rAF it keeps firing in
       throttled/background tabs, so playback never freezes on one frame */
    let lastF = -1;
    while (playRef.current === token) {
      const T = ((performance.now() - token.start) / 1000) % D;
      const f = Math.floor(T * 30);
      if (f !== lastF) {
        lastF = f;
        try {
          vidSyncPreview(envRef.current, 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);
        } catch (e) { console.error('play frame failed', e); }
      }
      await vidYield();
    }
  };

  const record = async () => {
    if (busyRef.current) return;
    stopLoop();
    if (!envRef.current) { const ok = await prepare(); if (!ok) return; }
    const D = video.duration || 15;
    busyRef.current = true;
    try {
      const env = envRef.current;
      vidPauseMedia(env);
      const hasVideo = !!(env.videoSync && env.videoSync.length);
      const aacCfg = await vidPickAacConfig();
      let pcm = null;
      if (video.audio && video.audio.key) {
        if (!aacCfg) {
          onStatus && onStatus('⚠ This browser can\'t encode AAC — exporting silent. Use desktop Chrome / Safari for music.');
          await new Promise(r => setTimeout(r, 2500));
        } else {
          onStatus && onStatus('Loading soundtrack…');
          pcm = await vidLoadAudioPCM(video.audio.key, D, aacCfg.sampleRate);
          if (!pcm) onStatus && onStatus('Soundtrack failed to decode — exporting silent');
        }
      }
      let blob = null, ext = 'mp4';
      if (window.Mp4Muxer && typeof VideoEncoder !== 'undefined') {
        if (hasVideo) {
          /* realtime mp4: videos play naturally inside the screen — no seeks */
          await vidPrimeMedia(env);
          blob = await vidEncodeMp4Realtime(canvasRef.current, drawAt, D, 30,
            T => vidSyncPreview(env, T), msg => onStatus && onStatus(msg), pcm, aacCfg);
          vidPauseMedia(env);
        } else {
          /* stills only → fast offline encode */
          blob = await vidEncodeMp4(canvasRef.current, drawAt, D, 30,
            p => onStatus && onStatus(`Encoding ${Math.round(p * 100)}%…`), null, pcm, aacCfg);
        }
      }
      if (!blob) {
        /* realtime capture: videos play naturally inside the screen — no seeks */
        onStatus && onStatus('Recording…');
        await vidPrimeMedia(env);
        const res = await vidRecordRealtime(canvasRef.current, drawAt, D,
          msg => onStatus && onStatus(msg), T => vidSyncPreview(env, T));
        if (!res) { onStatus && onStatus('Recording not supported in this browser'); busyRef.current = false; return; }
        vidPauseMedia(env);
        blob = res.blob; ext = res.ext;
      }
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `${((window.CURRENT_BRAND || {}).name || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '')}-preview-${video.device}-${String((idx || 0) + 1).padStart(2, '0')}.${ext}`;
      a.click();
      onStatus && onStatus(ext === 'mp4' ? 'Saved ✓ .mp4' : 'Saved ✓ .webm — convert to .mp4/.mov for App Store');
      setTimeout(() => onStatus && onStatus(null), 4000);
      drawAt(Math.min(2.4, D));
    } catch (e) {
      console.error(e);
      onStatus && onStatus('Export failed');
    }
    busyRef.current = false;
  };

  useImperativeHandle(ref, () => ({ record, isReady: () => ready }));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
      <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={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <button style={btnStyle({ padding: '8px 18px' })} onClick={play}>{playing ? 'Pause' : 'Play'}</button>
        <span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>
          {(window.VIDEO_DEVICES[video.device] || window.VIDEO_DEVICES.iphone).label} · {dims.w}×{dims.h}px · {video.duration || 15}s
          {(window.Mp4Muxer && typeof VideoEncoder !== 'undefined') || vidPickMime().indexOf('mp4') !== -1 ? ' · exports .mp4' : ' · exports .webm'}
        </span>
      </div>
    </div>
  );
});

Object.assign(window, { VideoComposition, VideoStage, captureVideoEnv, buildVideoOps, vidLoadAudioPCM,
  vidLoadAudioStream,
  vidMediaPut, vidMediaGet, vidMediaDel, vidYield, getFontEmbedCss, vidProbeVideo, VID_CODEC_MSG,
  vidEncodeMp4, vidEncodeMp4Realtime, vidRecordRealtime, vidPickAacConfig, vidPrimeMedia,
  vidPauseMedia, vidDisposeEnv, vidEase, vidRoundRect, vidDrawCover, vidPickMime, vidScreenRect,
  getFontEmbedCss2: getFontEmbedCss });
