kinetograph.

What works

What Kinetograph draws from a Remotion or HyperFrames project, and what it approximates or skips.

Kinetograph renders Remotion and HyperFrames projects without a browser. Your remotion and @remotion/* imports run on Kinetograph's own versions of those packages. To move a project, see Migrate from Remotion.

Each section lists what works, then the gaps. "Reported" means it shows in the render warnings and in doctor.

Layers blend in sRGB, like a browser. Set blendSpace: 'linear' for linear-light blending.

When the engine can't draw something

The engine never switches to a browser. It skips the item or draws something close, and warns.

  • render, still and strip print one line per kind of problem (element, property, reason, frames), at most 12 lines. Only frames where the element is on screen count.
  • --strict stops the command at the first frame with anything unsupported: error code UNSUPPORTED_CONTENT, exit code 1, no output file. Use it in CI and final renders.
  • kinetograph doctor <entry> goes through every frame without the GPU and lists what can't be drawn, on which frames, and any scene a render would refuse. Run it before a long render. It takes a HyperFrames index.html too.
  • --json puts the full list under compat.leftOut. doctor is also an MCP tool.
npx kinetograph doctor src/index.ts
npx kinetograph render src/index.ts --strict

Text and fonts

  • Fonts load from files, new FontFace(...) with document.fonts.add(face), or @font-face. One file is kept per family, weight and style; a unicodeRange split keeps the basic Latin part.
  • -webkit-text-stroke, paint-order: stroke fill, text-shadow, transforms, opacity and background-clip: text work.
  • Kinetograph reads no system fonts, so a render looks the same everywhere. With no font loaded, text uses the bundled DM Sans. Variable fonts follow the requested weight.
  • A missing character comes from Noto Sans Symbols 2, Noto Sans Math or Noto Emoji. Emoji are monochrome.
  • CJK, Arabic, Devanagari and similar scripts have no bundled font. They are drawn as boxes and reported. Load a font that has them.
  • monospace falls back to a proportional face, so code blocks (Shiki, Code Hike) look wider than in Chrome. Load a monospace font.
  • ex uses the first font's x-height. With only a generic family such as serif, a MathJax formula comes out about 10% larger. ex in SVG shape attributes is half an em.

Video and audio

  • <Audio> and <Video> from @remotion/media work. toneFrequency (0.01 to 2) changes pitch, not length.
  • @remotion/media-utils works. visualizeAudio follows Remotion's documented behaviour, but its numbers are not identical.
  • onVideoFrame on <Video>, <OffthreadVideo> and <Html5Video> hands you the frame as a canvas image source: ctx.drawImage(frame, ...) and getImageData. The frame is not a WebCodecs VideoFrame: it has width, height, timestamp and close() only. A canvas you draw on starts each frame empty. A video with no named colour space converts differently from Chrome.
  • <Video datamosh> is Kinetograph's own prop: see Datamosh.
  • @remotion/media-parser and @remotion/webcodecs work in part: the @remotion/webcodecs encode and decode functions, and the box tree and sample callbacks of @remotion/media-parser, throw UnsupportedApiError. Normal <Video> and <Audio> are not affected.
  • On Linux the addon has no VAAPI or Quick Sync encoders. Use your system's with KINETOGRAPH_FFMPEG=$(which ffmpeg) and --encoder h264_vaapi, hevc_vaapi, h264_qsv or hevc_qsv (yuv420p; yuv420p10le for hevc_*). They are never the default (libopenh264 is). KINETOGRAPH_VAAPI_DEVICE picks another /dev/dri/renderD*. --crf N maps to constant quality, which is a different picture quality. Untried on hardware.

CSS and SVG

  • import './index.css', local @imports, Tailwind (built with your project's setup) and *.module.css work. staticFile reads public/ next to the entry, else next to the nearest package.json.
  • filter: blur(), drop-shadow() and backdrop-filter take any radius; past 532 px they are drawn 532 px wide, as in Chrome. backdrop-filter: blur() reads only the backdrop inside the element's border box. The same filter functions work inside <svg>, next to filter: url(#id).
  • transform-style: preserve-3d planes that cross intersect, and are flattened where a browser flattens them (opacity below 1, filter, clip-path, mask-image, mix-blend-mode, non-visible overflow).
  • A backdrop-filter in a preserve-3d context inside an element drawn as one group blurs only what its own 3D context drew, not the whole group as Chrome does. Reported as approximated.
  • Crossing preserve-3d planes are cut using each border box, so a shadow past the box doesn't count. The crossing line isn't anti-aliased, and a layer cut more than 16 times can show a faint seam. A backdrop-filter on a turned element mirrors the backdrop at its bounding box.
  • A basic shape such as inset() as clip-path inside an <svg> is not drawn.
  • A rule that sets a property to its initial value loses to a presentation attribute: .bar { fill: black } over fill="red" stays red.
  • HTML is laid out as a browser would. A group of HTML elements is a box the size of the composition, as Remotion's root div is. A Kinetograph element inside HTML takes the size of its own box, so a layer with no box is 0 × 0. CSS @keyframes follow the frame's time.

Canvas 2D, effects and shaders

  • A url() filter on Canvas 2D works when it names an SVG <filter> of the page. On a <Canvas> component it is reported.
  • HtmlInCanvas effects (2d, webgl2, webgpu), onPaint and onInit run. State from setup doesn't carry to the next frame, since each frame runs setup, apply and cleanup again. getImageData of the source and nested <HtmlInCanvas> are not supported. An async hook holds the frame until its promise settles; setTimeout waits don't.
  • WGSL shader() sees only its own layer, runs one pass, has no other texture, and takes f32 uniforms only.

WebGL

<canvas> with a webgl or webgl2 context runs on the GPU. p5.brush's standalone build draws as in Chrome.

  • Works: fragment shaders, framebuffers, gl_PointSize, instancing, blitFramebuffer, depth and stencil tests, drawBuffers, depth textures.
  • Every frame starts with an empty canvas, also with preserveDrawingBuffer, because a frame is a function of its own number.
  • Not drawn: multisampled renderbuffers, readPixels, lines, instanced points and a vertexAttribDivisor above 1, cube textures, float render targets (float textures from texImage2D and 3D textures work), sampler2DShadow, non-RGBA render targets, mixed-size framebuffer attachments, generateMipmap on a drawn-into texture, and a TEXTURE_BASE_LEVEL other than 0.
  • Differs from a browser: the depth buffer holds 32-bit floats, depth: false makes the depth test always pass, a depth texture is sampled NEAREST, points are squares of at most 511 px, and gl_VertexID with drawElements is the position in the index list.

WebGPU

A canvas's getContext('webgpu') records its calls, and the passes it submits are drawn on the engine's own device.

  • Works: buffers, WGSL modules, render and compute pipelines and passes, 2D textures in the colour formats that need no optional feature, storage buffers and textures, uploads, copies and multi-pass shaders. copyExternalImageToTexture takes images, ImageBitmap, canvases, <video> and effect layers, and writes the four 8-bit RGBA formats only.
  • Not drawn, reported every frame: query sets, render bundles, indirect draws, depth and stencil, multisampling, texture arrays, 1D and 3D textures, comparison samplers, viewFormats, importExternalTexture (use copyExternalImageToTexture), readback (mapAsync, copyTextureToBuffer), the canvas's own texture as a source, overridable constants, and depth, compressed and optional-feature formats.
  • An rgba16float or display-p3 canvas is clipped to SDR sRGB, and toneMapping: { mode: 'extended' } is drawn as standard (reported).
  • A refused WGSL module, or a frame over 4 GiB of textures, 4096 steps or 4096 dispatches, gives a warning and a transparent canvas.
  • State from earlier frames (a simulation with loadOp: 'load') is replayed, so it is right in a sequence. A frame drawn alone, or by another worker, can't have it; it is reported and doctor lists it. To avoid that, clear or rewrite what a compute pass writes before using it, and bind only what a pass uses. atomic operations that depend on invocation order are not deterministic on a GPU.

3D

  • A <Model> file that uses something the 3D layer can't read is reported on every frame that draws it, naming the feature (for example KHR_materials_clearcoat). A file the CLI can't read at all fails the render. See the scene format.
  • A second set of texture coordinates, alphaMode: MASK (drawn opaque) and glTF extensions are not read, and are reported.
  • Shadow maps may hold at most 1 GiB, counted at the largest mapSize (48 maps of 2048). A point light takes 6 maps. A scene over the limit is refused on load.

Remotion packages

  • Works: @remotion/transitions (all 19 presentations), light-leaks, starburst, sfx, zod-types, timeline-utils, canvas, motion-blur, layout-utils and captions.
  • @remotion/skia and @shopify/react-native-skia: real Skia in an optional add-on, on the CPU. Drive Reanimated values from useCurrentFrame(); withTiming, withSpring and other clock animations jump to their end value and are reported. useVideo shows frames but no sound. Not supported: makeImageFromView, Skia.SVG.MakeFromData and touch. If Graphite can't start on a Vulkan device (Linux, Windows), drawing falls back to the CPU; the warning and rendererReason in doctor --json say why. KINETOGRAPH_SKIA_CPU=1 chooses the CPU. See AUTHORING.md, section 7.7.
  • @remotion/rive: Rive's own runtime, with stateMachineInputs, assetLoader and referenced assets. An asset on Rive's CDN isn't fetched and is reported. getRenderer() returns null. See AUTHORING.md, section 7.6.

GSAP

  • GSAP (core, free plugins and Flip) works on the page's elements and SVG. Build a paused timeline in a layout effect and seek it to frame / fps, as Remotion documents. A tween that plays by itself, such as gsap.to(...) in a useEffect, also works, because Kinetograph moves GSAP's root clock with the frame, with one warning.
  • A jump to a later frame renders the skipped frames without drawing them, so tweens made in effects run. This costs one React render per skipped frame in a project that imports gsap. Motion-blur sub-frames run onUpdate only, not onStart, onComplete, onRepeat or call().
  • ScrollTrigger and Draggable don't apply to a render.

Libraries

How to write code for GSAP, Motion, anime.js, d3, Chart.js, Recharts and the rest is in Libraries.

  • requestAnimationFrame and Web Animations (element.animate()) run on the frame's clock, so a frame alone equals the frame in sequence. A pseudo-element animation is not drawn and warns once.
  • Motion clamps each step to 40 ms, so below 25 fps it runs a little slow. Motion components that mount mid-video start at the frame they mount on, but in a still or a later worker at their own first frame. Mount them at frame 0 or use @kinetographdev/motion.
  • Mermaid 12 draws flowchart, class, state, ER, sequence and pie diagrams. Register your font before mermaid.render. A <foreignObject> with a percentage size isn't drawn; mermaid.initialize({ htmlLabels: false }) gives plain SVG text.
  • A library that reads the wall clock is not frame-pure.
  • @paper-design/shaders 0.0.82: eight shaders match Chrome; others untested.

HyperFrames projects

render, still, doctor and studio take a HyperFrames project as it is. Pass its index.html or its directory. Nothing is rewritten. For the steps, see Migrate from HyperFrames.

  • Composition and timelines: every frame, each window.__timelines entry is paused and seeked. The root data-composition-id element gives the size (data-width, data-height) and length (data-duration, else the longest timeline or clip), at 30 fps. data-start takes seconds or clip-id + 2; its references stay inside one composition. Without data-duration, a video or audio lasts its file and an <img> lasts 3 s. data-hidden, data-composition-src, data-variable-values and getVariables() work.
  • Media: <video> is decoded by the engine, frame-accurate, following data-media-start and data-playback-rate (kept to 0.1 to 10). <audio> and unmuted <video> are mixed with data-volume, data-fade-in and data-fade-out. data-automation takes rate lanes and volume lanes (capped at +12 dB).
  • Audio effects: data-fx-chain plays all HyperFrames audio effects on an element, an <hf-audio-group> or the master bus, with presets and fx.* lanes. An unreadable chain stops the mix with an error naming the element.
  • Colour grading: data-color-grading on <video>, <img> and <canvas> runs HyperFrames' whole grading shader as one GPU pass: adjust, wheels, curves, hueCurves, secondaries, .cube luts, intensity, details, all 18 effects and the palette. A missing .cube drops the lut and says so.
  • Fonts: the 18 families HyperFrames embeds come in every weight Google serves, from the cache fetch-deps fills. A render never downloads a font. System names map as in HyperFrames: Arial to Inter, Consolas to JetBrains Mono, Georgia to EB Garamond. A page that calls measureText before its @font-face loads gets fallback widths in Chrome; Kinetograph measures with the registered font, so it can pick a larger size.
  • Scripts: <script type="importmap"> is read (one with a src is ignored, as in Chrome), and each module is bundled and runs after the classic scripts. Top-level await works, but the runtime doesn't wait for it. CSS and JSON import attributes are not supported.
  • WebGPU: pages that use it, such as the shaders.iife.js blocks (aurora, blob, ripples, swirl), are drawn natively. See WebGPU above.
  • Three.js is not drawn. A page that loads it is reported on every frame, and --strict fails.

Downloads and the lock file

Nothing is downloaded while a frame is drawn. Before frame 0, commands that open a project download what the page loads from the web.

kinetograph render my-video                       # downloads first, then renders offline
kinetograph render my-video --offline             # never downloads; an uncached file is an error
kinetograph fetch-deps my-video                   # the download alone; --update refreshes it
  • Allowed hosts (https): cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com, esm.sh and cdn.skypack.dev. --allow-host adds one, and --url adds a file a scan missed.
  • This covers scripts, modules, data files a script names, remote <video> and <audio> (500 MB each, --max-media-mb) and pictures (50 MB each).
  • A file that isn't cached stops the command, naming the URL and fetch-deps. A picture is drawn without and reported.
  • A module or picture URL built at run time is found by running the first frames in the pre-pass. One seen only in an unsampled frame is downloaded on first use and noted; with --offline a module is an error naming the specifier. doctor gives the fetch-deps command for a picture it can't find.
  • kinetograph.lock.json beside the entry stores each file's SHA-256 and size; commit it. A changed file stops the command until fetch-deps --update.

Not planned

  • IFrame is not drawn, because a real webpage needs a browser (reported).
  • Internals, FolderContext, PathInternals and AudioForPreview are internal to Remotion and not exported.
  • @remotion/three and @remotion/player are replaced by the engine's 3D layer and studio. renderer, bundler, cli and lambda are replaced by the Kinetograph CLI.
  • tailwind and tailwind-v4 only configure webpack, so do nothing. Tailwind itself works.

Can differ by a pixel

Rarely, when renderers share the GPU, two renders at the same concurrency differ by 1 in one colour channel on a few pixels of a frame. The cause is unknown. For a bit-exact render, use --concurrency 1 or KINETOGRAPH_SKIA_CPU=1 (slower, slightly different pixels).

On this page