Skip to content

Examples ​

Each example is a complete function you can copy. They are compiled against the package's own types on every change to web360, so they stay correct as it evolves.

Render a span to MP4 ​

Encode the stabilized frames into an MP4 in memory with Mediabunny. A 4K 360 clip runs to hundreds of megabytes a minute: for long spans, write to a file instead of memory (Mediabunny's StreamTarget).

ts
// Render a span of a recording to an MP4 file in memory, encoded with
// Mediabunny.
import { openRendition } from '@bvdmitri/web360';
import {
  BufferTarget,
  Mp4OutputFormat,
  Output,
  Quality,
  VideoSample,
  VideoSampleSource,
} from 'mediabunny';

// An MP4 written to memory, with one H.264 video track.
function mp4(): {
  output: Output<Mp4OutputFormat, BufferTarget>;
  video: VideoSampleSource;
} {
  const output = new Output({
    format: new Mp4OutputFormat(),
    target: new BufferTarget(),
  });
  const video = new VideoSampleSource({
    codec: 'avc',
    quality: new Quality({ bitrate: 30e6 }),
  });
  output.addVideoTrack(video);
  return { output, video };
}

export async function renderToMp4(
  file: File,
  from: number,
  to: number,
): Promise<Blob> {
  const rendition = await openRendition(
    { main: { blob: file, name: file.name } },
    { width: 3840, from, to, stabilization: 'smooth' },
  );
  const { output, video } = mp4();
  await output.start();
  try {
    for await (const frame of rendition.frames()) {
      const sample = new VideoSample(frame); // keeps the frame's timestamp
      await video.add(sample);
      sample.close();
      frame.close();
    }
  } finally {
    rendition.dispose();
  }
  await output.finalize();
  const bytes = output.target.buffer ?? new ArrayBuffer(0);
  return new Blob([bytes], { type: 'video/mp4' });
}

A preview frame ​

One stabilized frame as an ImageBitmap, to draw on a canvas or turn into a thumbnail.

ts
// Show one stabilized frame of a recording, for a thumbnail or a preview.
import { openRendition } from '@bvdmitri/web360';

// Half a second of the recording always holds a frame to show.
const SPAN_SECONDS = 0.5;

export async function previewFrame(
  file: File,
  at: number,
): Promise<ImageBitmap | undefined> {
  const rendition = await openRendition(
    { main: { blob: file } },
    { width: 1024, from: at, to: at + SPAN_SECONDS },
  );
  try {
    const { value: frame } = await rendition.frames().next();
    if (frame === undefined) return undefined;
    const bitmap = await createImageBitmap(frame);
    frame.close();
    return bitmap;
  } finally {
    rendition.dispose();
  }
}

An Insta360 split pair ​

ts
// The Insta360 ONE R writes each lens to its own file:
// VID_<date>_<time>_00_<seq>.insv carries the recording's metadata, and the
// matching _10_ file holds the other lens. Pass them as `main` and
// `second`; a single-file recording (X3 and later) needs only `main`.
import { openRendition, type Rendition } from '@bvdmitri/web360';

export async function openInsta360(
  files: readonly File[],
): Promise<Rendition> {
  const main = files.find((file) => /_00_\d+\.insv$/iu.test(file.name));
  if (main === undefined)
    throw new Error('the _00_ file of the recording is missing');
  const second = files.find((file) => /_10_\d+\.insv$/iu.test(file.name));
  return openRendition(
    {
      main: { blob: main },
      ...(second !== undefined && { second: { blob: second } }),
    },
    { width: 2880, stabilization: 'horizon' },
  );
}

A GoPro recording in chapters ​

ts
// A GoPro recording longer than about 4 GB is split into chapters:
// GS01nnnn.360, GS02nnnn.360, and so on. Pass the first as `main` and the
// rest, in order, as `following`.
import {
  openRendition,
  type RecordingFile,
  type Rendition,
} from '@bvdmitri/web360';

const chapterOf = (file: File): number =>
  Number(/^GS(\d{2})\d{4}\.360$/iu.exec(file.name)?.[1]);

export async function openGoPro(
  files: readonly File[],
): Promise<Rendition> {
  const [first, ...rest] = files
    .filter((file) => file.name.toLowerCase().endsWith('.360'))
    .toSorted((a, b) => chapterOf(a) - chapterOf(b))
    .map((file): RecordingFile => ({ blob: file, name: file.name }));
  if (first === undefined)
    throw new Error('no .360 chapter among the files');
  return openRendition({ main: first, following: rest }, { width: 3840 });
}

Progress and cancelling ​

ts
// Report progress while frames come, and stop on request: an aborted signal
// ends `frames()` with the signal's reason, and `dispose()` releases the
// GPU.
import { openRendition } from '@bvdmitri/web360';

const MICROSECONDS_PER_SECOND = 1_000_000;

export async function renderWithProgress(
  file: File,
  handle: (frame: VideoFrame) => Promise<void>,
  options: { signal: AbortSignal; onProgress: (done: number) => void },
): Promise<void> {
  const rendition = await openRendition(
    { main: { blob: file } },
    { width: 2880, signal: options.signal },
  );
  try {
    for await (const frame of rendition.frames()) {
      // Timestamps are microseconds from the span's start; the span's
      // length is in seconds.
      options.onProgress(
        frame.timestamp / MICROSECONDS_PER_SECOND / rendition.duration,
      );
      await handle(frame);
      frame.close();
    }
  } finally {
    rendition.dispose();
  }
}

Errors a user can act on ​

ts
// Every failure to open or render is a Web360Error whose code says why;
// turn it into a message.
import {
  openRendition,
  Web360Error,
  type RenditionSource,
  type Web360ErrorCode,
} from '@bvdmitri/web360';

const MESSAGES: Record<Web360ErrorCode, string> = {
  'render-unavailable':
    'This browser has no WebGPU. Try a current Chrome or Edge.',
  'webcodecs-unavailable':
    'This browser cannot decode video with WebCodecs.',
  'codec-unsupported':
    "This browser cannot decode this camera's video (often 8K HEVC).",
  decode: 'The video could not be decoded partway through.',
  'missing-second-file':
    'This recording has two lens files: add the matching _10_ file.',
  'unreadable-recording': 'This file is not a recording web360 reads.',
};

export async function describeFailure(
  source: RenditionSource,
): Promise<string | undefined> {
  try {
    const rendition = await openRendition(source, { width: 256 });
    rendition.dispose();
    return undefined;
  } catch (error) {
    if (error instanceof Web360Error) return MESSAGES[error.code];
    throw error;
  }
}

In a Web Worker ​

The worker side; the page creates it with new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) and posts { file } to it.

ts
// Rendering is heavy: run it in a dedicated Web Worker so the page stays
// responsive. The page posts the picked file; the worker renders and posts
// each frame back (a VideoFrame transfers).
import { openRendition } from '@bvdmitri/web360';

globalThis.addEventListener(
  'message',
  (event: MessageEvent<{ file: File }>) => {
    void (async (): Promise<void> => {
      const rendition = await openRendition(
        { main: { blob: event.data.file } },
        { width: 1920 },
      );
      try {
        for await (const frame of rendition.frames())
          globalThis.postMessage(frame, { transfer: [frame] });
      } finally {
        rendition.dispose();
      }
    })();
  },
);

Released under the MIT License.