@bubo-squared/gyroview
v0.8.1
Published
Play raw Insta360 .insv recordings (X3, X4, X5, X6) in the browser: a <gyro-view> web component that stitches and gyro-stabilizes the camera's dual-fisheye video on the GPU.
Readme
@bubo-squared/gyroview
Play raw .insv recordings of Insta360 cameras (X3, X4, X5, X6, ONE RS) and the Antigravity A1
in the browser. <gyro-view> reads the camera's dual-fisheye file directly, over HTTP byte
ranges or from a local file, decodes both lenses in hardware with WebCodecs, and stitches and
gyro-stabilizes them on the GPU. No Insta360 Studio export step.
Try it first: insv-player.com plays .insv files with this
package. Drop your own recording to check that your camera and browser work before you install.
Install
npm install @bubo-squared/gyroviewIt brings Three.js 0.186 and mediabunny 1 along. A page that uses Three.js 0.186 itself shares
that copy; any other version means two copies in the bundle, about 128 KB gzipped. The package is
ES modules only: CommonJS code loads it with import().
Use
Register the element once, then use it like a video element:
import '@bubo-squared/gyroview/define';<gyro-view
src="https://media.example/VID_20260814_132640_00_013.insv"
stabilization="lock"
controls
muted
></gyro-view>A relative src resolves against the document, as an image's does. timeupdate comes four
times a second while playing, as a media element's does; frame comes with every picture
drawn.
A page without a bundler loads the standalone file, which has Three.js and mediabunny inside (about 260 KB compressed) and registers the element; it exports what the package does:
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@bubo-squared/[email protected]/dist/standalone.js"
></script>
<script type="module">
import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/[email protected]/dist/standalone.js';
</script>Bundling it next to a Three.js of the page's own ships two copies; @bubo-squared/gyroview
and @bubo-squared/gyroview/define share the page's instead.
Play a file the visitor picks, without any server:
const player = document.querySelector('gyro-view');
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
input?.addEventListener('change', () => {
const file = input.files?.[0];
if (file && player) player.loadFiles({ main: file });
});@bubo-squared/gyroview/define registers <gyro-view> when imported. To choose the moment
yourself, import defineGyroView from @bubo-squared/gyroview and call it. Both are safe to
import in a framework that renders on the server: there they register nothing, and the element
comes alive once the page runs in the browser.
The element's events are typed: each is a CustomEvent with its payload in detail, and
document.querySelector('gyro-view') is a GyroViewElement.
player?.addEventListener('ready', (event) => console.log(event.detail.model));
player?.addEventListener('error', (event) => console.log(event.detail.code));
player?.addEventListener('warning', (event) => {
if (event.detail.code === 'autoplay-blocked') showTapToPlay();
});Its words are English until the page gives its own, and a failure shows visitors a plain
sentence while the error event carries the diagnostic:
if (player) player.messages = { labels: { play: 'Lecture' }, errors: { cors: 'Introuvable.' } };Read what a recording holds without playing it: its trailer records, the info record (camera, firmware, frame rate), the lens calibration and summaries of the gyro and exposure records.
import { inspectRecording } from '@bubo-squared/gyroview';
const fromFile = await inspectRecording(file);
const fromUrl = await inspectRecording('https://media.example/VID_20260814_132640_00_013.insv');For an interface of your own, createBrowserPlayer gives the player without the element: it
draws on your canvas, sounds through your audio element and has the same events, which
player.events.on listens to. player.togglePlayback() plays or pauses as the element's tap
does, a refused start reported as a warning.
attachViewGestures and attachKeyboard give your canvas the element's drags, pinches, wheel
zoom and taps, and its keyboard shortcuts; each returns what removes them.
import { createBrowserPlayer } from '@bubo-squared/gyroview';
const player = createBrowserPlayer({ canvas, audio });
player.events.on('timeupdate', (time) => console.log(time));
attachViewGestures(canvas, player);
attachKeyboard(canvas, player);
await player.load({ main: { url: 'https://media.example/VID_20260814_132640_00_013.insv' } });
await player.play();
player.seek(30);
player.lookAt(90, 0);The canvas keeps no picture once the browser has shown it: a snapshot (drawImage, toBlob)
is taken in a frame listener, which runs right after each picture is drawn. There are no
buffered ranges to show yet, and no playback rates other than 1.
How a recording is downloaded
A recording named by URL is read in byte ranges, in file order, as it plays: the lenses and
the sound come from the same bytes, each fetched about once. Until the first play the player
reads only what opening it and showing the first frame need (no frame with preload="none").
Playing, it keeps up to 10 s or 128 MiB ahead, whichever is less, shared by the two files of a
split recording; paused, it reads on to that budget and stops. A seek ends the old position's
requests at once. When the network falls behind the recording, playback waits until the next
4 s are downloaded, and plays on in stretches rather than a frame at a time. A range that
breaks off or stalls for 10 s is asked for again from its next byte, and a recording replaced at
its URL while it plays fails with source-changed. A server slow to answer each request, such
as a cloud drive's download API, is asked in fewer, larger ranges, so its waits do not slow
playback.
@bubo-squared/gyroview also exports GyroViewError, the list of its codes
(GYRO_VIEW_ERROR_CODES, with isGyroViewErrorCode to check a string against it), and the
types of the element's settings, metadata, events and inspection.
Frameworks
The element takes properties and attributes as any element does, and its events are DOM events, so a framework needs only to know the tag.
React 19 sets the attributes and hears events through a ref. For JSX to know the tag, a
declaration file of the project's own extends React's elements with the package's
GyroViewAttributes:
// gyro-view.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import type { GyroViewAttributes, GyroViewElement } from '@bubo-squared/gyroview';
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'gyro-view': DetailedHTMLProps<HTMLAttributes<GyroViewElement>, GyroViewElement> &
GyroViewAttributes;
}
}
}const player = useRef<GyroViewElement>(null);
useEffect(() => {
const element = player.current;
const onReady = (event: CustomEvent<PlayerMetadata>): void => console.log(event.detail.model);
element?.addEventListener('ready', onReady);
return () => element?.removeEventListener('ready', onReady);
}, []);
return <gyro-view ref={player} src={url} stabilization="lock" controls muted />;A fetch for recordings behind a token is a property only, with no attribute. Set it where
React commits src, in a ref callback or useLayoutEffect, not in useEffect, which runs after
the load has started without it; and keep the function stable (useCallback, or defined outside
the component), since only setting the same function again leaves the recording playing:
const withToken = useCallback<RecordingFetch>((url, init) => {
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token.current}`);
return fetch(url, { ...init, headers });
}, []);
useLayoutEffect(() => {
if (player.current) player.current.fetch = withToken;
}, [withToken]);Vue 3 is told the tag is a custom element (isCustomElement: (tag) => tag === 'gyro-view'
in the template compiler options), then binds as usual: <gyro-view :src="url" controls
@ready="onReady" />.
Svelte 5 needs nothing: <gyro-view src={url} controls onready={onReady} />.
In every case the element registers in the browser only: import @bubo-squared/gyroview/define
in code that runs there, or call defineGyroView() once mounted. Importing either while the
server renders is safe and registers nothing.
Several players on one page
Each player holds a WebGL context and, while a recording is loaded, two hardware video
decoders. Browsers cap both (about sixteen WebGL contexts in Chrome, fewer decoders on phones),
so a gallery gives its players preload="none", which keeps the decoders idle until play, and
loads a recording only for the player in view, removing src from the others. An element the
page removes gives its WebGL context up at once rather than when it is collected, and draws on a
fresh canvas if it is added again; one whose context the browser took away while it held no
recording draws on a fresh canvas at its next load.
Requirements
A secure page. WebCodecs exists only on
https://pages, orhttp://localhost; elsewhere the player fails withwebcodecs-unavailable.Recordings served in byte ranges. The server answers
Rangerequests with206, and sends CORS headers when the recordings live on another origin than the page:Access-Control-Allow-Origin: https://your-site.example Access-Control-Allow-Methods: GET, HEAD Access-Control-Allow-Headers: Range Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges, ETagRecordings kept behind the visitor's cookies take
crossorigin="use-credentials"on the element, orcredentials: 'include'on a URL given tocreateBrowserPlayer'sload, and a host that names the page's origin, never*, and addsAccess-Control-Allow-Credentials: true.Recordings behind a token take the page's own function in the element's
fetchproperty, or infetchon a URL given toload: it is called in place offetchfor every request, and adds the header the host asks for.player.fetch = (url, init) => { const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token()}`); return fetch(url, { ...init, headers }); };A hardware HEVC decoder. 5.7K plays on recent laptops and phones; 8K needs a Level 6 decoder (Apple Silicon, recent NVIDIA and Intel). On Linux, Chrome reaches the decoder only through VA-API: an Intel or AMD GPU whose driver offers HEVC, not NVIDIA's own driver or a virtual machine (
chrome://gpulistsDecode hevc mainunder Video Acceleration Information when it can). H.264 recordings do not need it. A recording the browser cannot decode fails with thecodec-unsupportederror.
The supported browsers, with the oldest versions that have what the player uses (WebCodecs,
WebGL 2, container queries, color-mix(), the ES2023 array methods, and on iPhone
ManagedMediaSource for the sound):
| Browser | From | Notes |
| --------------------- | ---- | ---------------------------------------------------------------------------------------- |
| Chrome, Edge desktop | 111 | The controls' colours need 111; HEVC is decoded in hardware from 107 on (Linux: VA-API). |
| Safari on macOS | 16.4 | |
| Safari on iPhone/iPad | 17.1 | 16.4 to 17.0 play without sound, with a warning event. |
Firefox and Chrome on Android are untested: they play what their decoders accept.
Until 1.0, a minor version may change the API; CHANGELOG.md says what changed.
Reference
Every attribute, method, event and keyboard shortcut is in the
project README, and what a failure
tells a page, with what a <video> fallback can do, under
When a recording cannot play;
hosting and the error codes are in docs/DEPLOYMENT.md.
License
MIT
