/* ===========================================================================
   iRaven App Store Studio — Source-video frame pre-decoder.

   Why this exists:
     The seek-based render path (set <video>.currentTime → await seeked →
     await requestVideoFrameCallback → draw) costs 30-100 ms per frame on
     a typical video. 30 s × 30 fps = 900 frames → 30-90 s wall-clock to
     export a 30 s preview. To the user it feels like "playback being
     recorded," not a render.

     This module replaces that loop with a one-shot decode pass:

       1. Demux the source mp4 with mp4box.js → EncodedVideoChunks +
          codec description.
       2. Feed every chunk into a WebCodecs VideoDecoder.
       3. Collect every produced VideoFrame, sorted by timestamp.
       4. Render loop calls getFrameAt(t) — O(log n) binary search,
          returns a VideoFrame that ctx.drawImage() consumes directly.

     Total decode time for a 10 s 1080p source is ~500-1500 ms on
     mid-range hardware; the render loop then drops to "as fast as
     JPEG encoding," which is the actual ceiling.

   Browser support:
     * Chrome 96+, Edge 96+, Safari 16.4+, Firefox 130+ ship WebCodecs.
     * Older browsers fall back to the seek path automatically — the
       caller checks `supportsVideoDecoder()` first.
     * Only mp4 (H.264, HEVC inside mp4) is supported by the demuxer.
       webm sources also fall back to the seek path.

   VideoFrame lifecycle:
     VideoFrame objects are GPU texture refs; each one MUST be .close()'d
     or the GPU memory is leaked. close() releases them all. Use a
     try/finally around the render loop on the caller side.
   =========================================================================== */

(function () {
  /* mp4box ships under window.MP4Box (UMD). Pre-resolve so static
     analysis is happy. */
  function getMp4Box() { return window.MP4Box; }

  function supportsVideoDecoder() {
    return typeof window.VideoDecoder !== 'undefined'
      && typeof window.EncodedVideoChunk !== 'undefined'
      && !!getMp4Box();
  }

  /* mp4box exposes the codec descriptor (avcC / hvcC) only via the raw
     track box. The standard recipe is to walk file.moov.traks[i].mdia
     .minf.stbl.stsd.entries[0].avcC (or .hvcC) and concat the box bytes
     starting after the 8-byte box header. */
  function buildDecoderDescription(mp4boxFile, trackId) {
    const trak = mp4boxFile.getTrackById(trackId);
    if (!trak) return null;
    const entries = trak.mdia.minf.stbl.stsd.entries;
    for (const entry of entries) {
      const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C;
      if (!box) continue;
      const stream = new (window.MP4Box.DataStream || window.DataStream)(undefined, 0, 1); // BIG_ENDIAN
      box.write(stream);
      // mp4box writes the full box (8-byte header + payload). Decoder
      // wants the payload only.
      return new Uint8Array(stream.buffer, 8);
    }
    return null;
  }

  /* Read the whole Blob and hand it to mp4box. For very large videos a
     streaming variant would be friendlier; the studio's source clips
     are typically under a few hundred MB so a single buffer is fine. */
  async function createFrameSource(blob) {
    if (!supportsVideoDecoder()) throw new Error('webcodecs-unavailable');
    const MP4Box = getMp4Box();

    const ab = await blob.arrayBuffer();
    const mp4 = MP4Box.createFile();

    const ready = new Promise((resolve, reject) => {
      mp4.onError = (err) => reject(new Error('mp4box: ' + err));
      mp4.onReady = (info) => {
        const track = info.videoTracks[0];
        if (!track) { reject(new Error('no video track')); return; }
        resolve({ info, track });
      };
    });

    const arr = ab;
    arr.fileStart = 0;
    mp4.appendBuffer(arr);
    mp4.flush();

    const { track } = await ready;

    /* Some browsers reject the codec string in track.codec (e.g.
       'avc1.640028' is OK; 'avc1.42E01E,mp4a.40.2' is NOT). Take the
       first comma-separated entry. */
    const codecStr = track.codec.split(',')[0];
    let description;
    try { description = buildDecoderDescription(mp4, track.id); }
    catch (e) { description = null; }

    const config = {
      codec: codecStr,
      codedWidth: track.video.width,
      codedHeight: track.video.height,
    };
    if (description) config.description = description;

    /* isConfigSupported can return supported=false for HEVC on Chrome
       Linux even when Safari handles it. Caller falls back when this
       throws. */
    const support = await window.VideoDecoder.isConfigSupported(config);
    if (!support || !support.supported) {
      throw new Error('codec-unsupported: ' + codecStr);
    }

    const frames = [];
    let decodeErr = null;
    const decoder = new window.VideoDecoder({
      output: (frame) => {
        frames.push(frame);
      },
      error: (e) => { decodeErr = e; },
    });
    decoder.configure(config);

    /* Pipe every sample into EncodedVideoChunks. timescale is on the
       track; cts/duration are in timescale units. */
    const timescale = track.timescale || 1;
    let chunkCount = 0;
    const samplesReady = new Promise((resolve) => {
      mp4.onSamples = (_id, _user, samples) => {
        for (const s of samples) {
          const chunk = new window.EncodedVideoChunk({
            type: s.is_sync ? 'key' : 'delta',
            timestamp: Math.round(1e6 * s.cts / timescale),
            duration: Math.round(1e6 * s.duration / timescale),
            data: s.data,
          });
          decoder.decode(chunk);
          chunkCount++;
        }
        /* mp4box doesn't tell us "no more samples" — once it stops
           calling onSamples we just wait for the decoder to drain. */
      };
      mp4.setExtractionOptions(track.id, null, { nbSamples: Infinity });
      mp4.start();
      /* Give the box machinery one tick to flush its parsed samples. */
      setTimeout(resolve, 0);
    });
    await samplesReady;

    await decoder.flush();
    decoder.close();
    if (decodeErr) throw decodeErr;
    if (frames.length === 0) throw new Error('no frames decoded');

    /* Sort by timestamp because VideoDecoder may reorder B-frames. */
    frames.sort((a, b) => a.timestamp - b.timestamp);

    /* Augment each VideoFrame with videoWidth/videoHeight props so the
       existing vidDrawCover (which reads .videoWidth || .naturalWidth)
       Just Works™ when we substitute m.el with one of these. */
    for (const f of frames) {
      try {
        Object.defineProperty(f, 'videoWidth', { value: f.displayWidth, configurable: true });
        Object.defineProperty(f, 'videoHeight', { value: f.displayHeight, configurable: true });
      } catch (e) {
        // Object may be sealed in some engines; the fallback in
        // makeFrameDrawable below covers that case.
      }
    }

    const totalUs = frames[frames.length - 1].timestamp + (frames[frames.length - 1].duration || 33000);
    const durationSeconds = totalUs / 1e6;

    /* Binary search: highest frame with timestamp ≤ t (seconds). */
    function getFrameAt(tSeconds) {
      const targetUs = Math.max(0, Math.min(totalUs - 1, Math.round(tSeconds * 1e6)));
      let lo = 0, hi = frames.length - 1, best = 0;
      while (lo <= hi) {
        const mid = (lo + hi) >> 1;
        if (frames[mid].timestamp <= targetUs) { best = mid; lo = mid + 1; }
        else hi = mid - 1;
      }
      return frames[best];
    }

    function close() {
      for (const f of frames) { try { f.close(); } catch (e) {} }
      frames.length = 0;
    }

    return {
      getFrameAt,
      close,
      duration: durationSeconds,
      frameCount: frames.length,
      width: track.video.width,
      height: track.video.height,
      codec: codecStr,
    };
  }

  /* For drawCover to read sizing off a VideoFrame even on engines where
     we couldn't defineProperty: return a small wrapper object. The
     wrapper isn't a CanvasImageSource so we don't use it for draw —
     just for size queries. The VideoFrame itself is drawn directly. */
  function frameDims(vf) {
    return {
      width: vf.displayWidth || vf.codedWidth || vf.videoWidth || 0,
      height: vf.displayHeight || vf.codedHeight || vf.videoHeight || 0,
    };
  }

  Object.assign(window, {
    createFrameSource,
    supportsVideoDecoder,
    frameDims,
  });
})();
