@nika-js/onlymap-remotion
v0.1.0-rc.1
Published
Render OnlyMap maps in Remotion videos. Bring a GeoJSON route, places, or a map story — camera, markers and duration are derived from your data, and every frame waits for the map to finish drawing.
Maintainers
Readme
@nika-js/onlymap-remotion
Render OnlyMap maps inside Remotion compositions. Bring a route, a list of places, or a map story — the camera, the markers, and the video's length are derived from that data, and every frame is held until the map has genuinely finished drawing.
npm install @nika-js/onlymap-remotion @nika-js/onlymap remotionThe problem this solves
Remotion makes videos by screenshotting a React page once per frame. A map is the worst possible thing to screenshot: tiles stream in asynchronously, labels settle over hundreds of milliseconds, and the camera animates on its own clock. Point Remotion at a map naively and you get blurry frames, missing labels, and a video that renders differently every time.
This package makes a map safe to screenshot — then gets out of Remotion's way, so the map is just another React element your titles, charts and audio can be composed against.
You may not need it. If the video is just the map moving,
npx onlymapjs record map.html --out flyby.mp4is one command with none of the caveats below. Reach for this package when you need content reacting to the map per frame, or one composition producing many videos.
Required setup
One step, and it is not optional:
// remotion.config.ts
import { Config } from "@remotion/cli/config";
Config.setChromiumOpenGlRenderer("angle"); // REQUIRED
Config.setDelayRenderTimeoutInMilliseconds(120_000);Remotion's default software renderer cannot create a WebGL context for
MapLibre — every render fails with BindToCurrentSequence failed. Programmatic
@remotion/renderer calls do not read the config file; pass
chromiumOptions: { gl: "angle" } there instead.
Quickstart
// Root.tsx
import { Composition, staticFile } from "remotion";
import { OmMapVideo, omMapVideoMetadata } from "@nika-js/onlymap-remotion";
const FPS = 30;
export const RemotionRoot = () => (
<Composition
id="flyover"
component={OmMapVideo}
fps={FPS}
width={1920}
height={1080}
durationInFrames={1} // replaced by calculateMetadata
defaultProps={{ src: staticFile("map.html"), route: myRoute, zoom: 14 }}
calculateMetadata={omMapVideoMetadata({ fps: FPS, metersPerSecond: 600 })}
/>
);npx remotion studio # scrub it
npx remotion render src/index.ts flyover out/video.mp4 --concurrency 2That is a finished flyover: the camera follows the route facing down it, with a duration computed from the route's real ground distance. No coordinates typed, no timing hand-tuned.
Choosing a camera source
The prop you pass decides how the camera moves. TypeScript permits only one at a time, so a contradictory composition will not compile.
| You have | Prop | Derived for you |
|---|---|---|
| GeoJSON LineString | route | position along the path, follow-bearing, duration from ground distance |
| A list of places | places | dwell-and-travel tour, duration from the list |
| An OnlyMap story | (none) | camera and duration from the story's own timeline |
| Composed shots | keyframes | interpolation between your poses |
<OmMapVideo src={src} route={route} zoom={14} pitch={55} />
<OmMapVideo src={src} places={[{ lngLat: [2.29, 48.86], zoom: 15, label: "Eiffel" }]} />
<OmMapVideo src={src} keyframes={shots} />
<OmMapVideo src={src} /> {/* the manifest's own <om-story> */}omMapVideoMetadata reads the same prop to derive the duration, so a longer
route is a longer video and an extra place is a longer tour. Hardcoding
durationInFrames is the one mistake that undoes the whole model.
Hand-typed coordinates are the escape hatch, not the starting point. If you are typing longitudes, you probably want a route file from geojson.io instead.
<OmMapVideo> props
| Prop | Type | Notes |
|---|---|---|
| src | string | Manifest URL — wrap local files in staticFile(). Mutually exclusive with html |
| html | string | Manifest markup inline, for a generated manifest |
| route | RouteInput | LineString, Feature, or bare [lng, lat][]. Pairs with zoom, pitch, bearing ("follow" by default), easing |
| places | FlyThroughPlace[] | Pairs with dwellFrames (30), travelFrames (60), easing |
| keyframes | CameraKeyframe[] | { frame, longitude, latitude, zoom, pitch?, bearing? }, with easing |
| storyId | string | Which <om-story> to drive, when the manifest has several |
| showTimeline | boolean | Register each story step as a named track in Studio's timeline |
| settleTimeoutMs | number | Per-frame tile budget, also the frame's delayRender timeout. Default 60_000 |
| previewGate | "fast" \| "exact" | Preview only. "fast" (default) makes scrubbing instant; rendering always gates exactly |
| failOnValidationError | boolean | Turn the map's severity-error validation entries into a failed render. Default false |
| onMapReady | (mapEl, storyEl) => void | Fires once — the hook for projection overlays |
| onFrameSettled | (info) => void | Per frame. settled: false means that frame may show unrefined tiles |
| children | ReactNode | Composited over the map |
Anything the wrapper cannot express — a spring, a data-driven zoom, a camera
chasing live telemetry — drops to the useOmManifest and useOmCamera hooks it
is built from, which stay public.
Overlays that track geography
The capability a recorded MP4 cannot offer: because the map is live DOM in your React tree, Remotion content stays pinned to real places as the camera flies.
import { OmMapVideo, useProjectedPoints } from "@nika-js/onlymap-remotion";
const [mapEl, setMapEl] = useState(null);
const { points, project } = useProjectedPoints(mapEl);
<OmMapVideo
src={src}
route={route}
onMapReady={setMapEl}
onFrameSettled={() => project({ hq: [-122.4014, 37.7935] })}
>
{points.hq && (
<div style={{ position: "absolute", left: points.hq[0], top: points.hq[1] }}>HQ</div>
)}
</OmMapVideo>useProjectedPoints exists because the commit must be flushSync inside
onFrameSettled — a plain setState lands after the frame is captured, so
every overlay would render one frame stale and visibly lag the map. Use
isOnScreen(point, width, height, margin) to cull anchors that leave the frame.
One composition, many videos
Because the manifest and the camera are both functions of props, thirty regional videos are thirty renders of one composition rather than thirty files to maintain:
npx remotion render src/index.ts flyover out/tokyo.mp4 --props='{"route": …}'This is the case onlymapjs record structurally cannot serve — it takes a
manifest file and produces one video.
Authoring aids
- Typed props panel —
@nika-js/onlymap-remotion/schemasexports zod schemas (cameraKeyframeSchema,flyThroughPlaceSchema,omAnchorSchema,keyframeCompositionSchema). Pass one to<Composition schema>and Studio renders form fields instead of raw JSON.zodis an optional peer and must match the version your Remotion bundles. - Render-cost readout —
useSettleStats()+<OmSettleHud>show frames, misses, hold times and a projected render time: is this scene render-affordable? Studio only; never appears in output. Holds read as zero under the default fast preview — measure withpreviewGate="exact".
Things that will surprise you
- Preview and render gate differently, on purpose. Studio releases each frame as soon as the camera is written, so scrubbing is instant and tiles pop in behind the playhead. Rendered output always waits for the map to settle.
- Studio cannot host interactive map UI. Its preview overlays consume
pointer events before a composition sees them, so buttons and map gestures
inside a composition receive nothing. This is Remotion's design — runtime
interactivity is a
@remotion/playerfeature. - Your manifest is rewritten before mounting. Every
<om-map>gains the recording switch,<script>tags are removed (they never execute underinnerHTML, and a second OnlyMap runtime would fail deck.gl's duplicate-version check), andloopis stripped. Each warns once. - Concurrency turns over. Measured on a basemap scene:
2→ 1.66×,4→ 1.90×,8→ slower than 4. Use--concurrency 2. Every worker is a tab with a cold tile cache, so start at1on metered tile providers. - Distributed rendering is out of scope. Remotion Lambda and Cloud Run have
no GPU, and ANGLE on a real GPU is the only verified way to get a WebGL
context. For batch work, run N independent
render --propsinvocations.
Determinism, and why it matters
The same frame renders byte-identically whether produced alone, in sequence, or out of order. That is what makes parallel rendering and frame caching safe — without it, "re-render just the part I changed" quietly yields a video that does not match itself.
It is enforced, not asserted: a render suite boots a real browser and
byte-compares frames across processes and orderings, and a parity harness runs
the same manifest through onlymapjs record and through this package —
currently 0.9999 structural similarity at aligned story time, across two
different Chromium and GL stacks.
Not a replacement for record
If you want an MP4 of a flyby with nothing layered on top, use
onlymapjs record — simpler, no extra licence to consider, and often faster.
The two compose: record once, then place the file like any other footage.
// npx onlymapjs record map.html --out public/flyby.mp4 --gpu
<OffthreadVideo src={staticFile("flyby.mp4")} />That skips this package's per-render cost entirely, at the price of a closed clip — nothing inside it can react to frames, props, or projection.
Requirements
| Peer | Version | Notes |
|---|---|---|
| @nika-js/onlymap | exact pin | It bundles deck.gl/luma.gl, which fail on duplicate-version detection |
| remotion | ≥ 4.0.342 | Uses the scoped useDelayRender hook |
| react, react-dom | ≥ 18 | |
| zod | optional | Only for /schemas; must match your Remotion's version |
API stability
Primary surface, treated as stable: OmMapVideo, omMapVideoMetadata,
OmStory, OmMapStill, useOmCamera, cameraAlongRoute, flyThrough,
cameraFromKeyframes, routeLengthMeters, pointAlongRoute,
flyThroughDuration, projectLngLat, isOnScreen, useProjectedPoints,
omStoryMetadata.
Secondary surface — supported, but shaped by internals and likelier to move
across minor versions: useOmManifest, useOmStoryFrame, prepareManifest,
measureStoryDuration, parseStorySteps, stepToFrames, setCameraPose,
readCameraPose, useSettleStats, OmSettleHud, ManifestError,
storyFrameCount, resolveGatePolicy, cameraSourceOf,
DEFAULT_SETTLE_TIMEOUT_MS.
Licensing
Free for non-commercial use — personal, educational, academic research, or evaluation — provided the OnlyMap attribution rendered into each frame stays visible in your output. Anything else (a business, a revenue-generating channel, paid client work) needs a commercial licence. Full terms in LICENSE.md.
Whether use is non-commercial is judged by what the video is for, not where
it was rendered. OnlyMap lifts its free-plan caps outside hosted http(s)
contexts and rendering always runs in such a context — so the caps being
inactive during a render is not a grant.
Two further licences apply and neither is ours to give: @nika-js/onlymap (its
own commercial licence) and remotion (source-available; may require a paid
licence by organisation size or render volume).
Independence. This is an independent integration. It is not affiliated with, endorsed by, or sponsored by Remotion. "Remotion" is a trademark of its respective owner, used here only to describe compatibility.
Documentation
- Guide: docs.nikaplanet.com/onlymap/remotion
- The package ships
llms.txtand an installable agent skill inside the tarball.
