zvidlib 0.4.1

Frame-accurate video and audio I/O for native and WebAssembly applications
Documentation
export class ZvidError extends Error {
  constructor(code, message) {
    super(message);
    this.name = "ZvidError";
    this.code = code;
  }
}

export function makeError(code, message) {
  return new ZvidError(code, message);
}

function cancellationError() {
  return makeError("CANCELLED", "the browser operation was cancelled");
}

async function abortable(promise, signal, onAbort) {
  if (!signal) return await promise;
  if (signal.aborted) {
    if (onAbort) await onAbort();
    throw cancellationError();
  }

  let rejectCancellation;
  const cancellation = new Promise((_, reject) => {
    rejectCancellation = reject;
  });
  const abort = () => {
    if (onAbort) Promise.resolve(onAbort()).catch(() => {});
    rejectCancellation(cancellationError());
  };
  signal.addEventListener("abort", abort, { once: true });
  try {
    return await Promise.race([promise, cancellation]);
  } finally {
    signal.removeEventListener("abort", abort);
  }
}

function checkedAppend(output, chunk, maxBytes) {
  if (!(chunk instanceof Uint8Array)) {
    throw makeError("INVALID_INPUT", "a ReadableStream chunk must be a Uint8Array");
  }
  if (output.length + chunk.byteLength > maxBytes) {
    throw makeError("RESOURCE_LIMIT", "browser input exceeds maxInputBytes");
  }
  const next = new Uint8Array(output.length + chunk.byteLength);
  next.set(output);
  next.set(chunk, output.length);
  return next;
}

export async function readBrowserSource(source, maxBytes, signal) {
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
    throw makeError("INVALID_INPUT", "maxInputBytes must be a non-negative safe integer");
  }

  if (source instanceof Blob) {
    if (source.size > maxBytes) {
      throw makeError("RESOURCE_LIMIT", "browser input exceeds maxInputBytes");
    }
    const buffer = await abortable(source.arrayBuffer(), signal);
    return new Uint8Array(buffer).slice();
  }

  if (source instanceof ReadableStream) {
    const reader = source.getReader();
    let output = new Uint8Array();
    try {
      while (true) {
        const result = await abortable(reader.read(), signal, () => reader.cancel());
        if (result.done) return output;
        output = checkedAppend(output, result.value, maxBytes);
      }
    } finally {
      reader.releaseLock();
    }
  }

  if (source instanceof ArrayBuffer) {
    const bytes = new Uint8Array(source);
    if (bytes.byteLength > maxBytes) {
      throw makeError("RESOURCE_LIMIT", "browser input exceeds maxInputBytes");
    }
    return bytes.slice();
  }

  if (ArrayBuffer.isView(source)) {
    const bytes = new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
    if (bytes.byteLength > maxBytes) {
      throw makeError("RESOURCE_LIMIT", "browser input exceeds maxInputBytes");
    }
    return bytes.slice();
  }

  throw makeError(
    "INVALID_INPUT",
    "source must be a Blob, ReadableStream<Uint8Array>, ArrayBuffer, or typed array",
  );
}

export function makeBlob(bytes, mimeType) {
  return new Blob([new Uint8Array(bytes).slice()], { type: mimeType });
}

export function makeTestStream(chunks) {
  return new ReadableStream({
    start(controller) {
      for (const chunk of chunks) controller.enqueue(new Uint8Array(chunk));
      controller.close();
    },
  });
}

export function makePendingStream() {
  return new ReadableStream({ pull() {} });
}

// Test-only: loads `blob` into a muted <video>, seeks it to `seekTo` seconds,
// and resolves [duration, seekable end, currentTime after the seek, videoWidth]
// once the browser has a decoded frame there. Rejects with the media error if
// the browser cannot play the file, and after ten seconds if it never settles.
export async function probeTestVideo(blob, seekTo) {
  const video = document.createElement("video");
  video.muted = true;
  video.preload = "auto";
  const url = URL.createObjectURL(blob);
  const settle = (event) =>
    new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error(`timed out waiting for ${event}`)), 10_000);
      video.addEventListener(
        event,
        () => {
          clearTimeout(timer);
          resolve();
        },
        { once: true },
      );
      video.addEventListener(
        "error",
        () => {
          clearTimeout(timer);
          reject(new Error(`video error ${video.error?.code}: ${video.error?.message}`));
        },
        { once: true },
      );
    });
  try {
    const loaded = settle("loadeddata");
    video.src = url;
    await loaded;
    const seeked = settle("seeked");
    video.currentTime = seekTo;
    await seeked;
    const seekableEnd = video.seekable.length > 0 ? video.seekable.end(video.seekable.length - 1) : 0;
    return [video.duration, seekableEnd, video.currentTime, video.videoWidth];
  } finally {
    video.removeAttribute("src");
    video.load();
    URL.revokeObjectURL(url);
  }
}