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();
}
})();
},
);