Real-time green/blue-screen keying for HTML video, in the browser. Point it at any flat green-screen video — a file URL, a <video> element, or a MediaStream. Get back a per-pixel transparent canvas. Composite it over anything on the page.
- Zero dependencies. One ES module file, no build step. Works with any framework or none.
- Unlimited concurrent players. All instances share one WebGL context (browsers cap live contexts at ~8-16), each blitting to its own lightweight 2D canvas.
- Robust keying. Dominant-channel classification with a continuous alpha ramp and spill suppression — no exact key color needed, survives codec color drift, removes green fringe from hair and edges.
- Works everywhere. WebGL with an automatic Canvas2D (CPU) fallback running the identical math, plus WebGL context-loss recovery.
- Optional edge dissolve. Blur + bottom vignette + top/bottom alpha fades that make a keyed presenter read as part of the page instead of a video rectangle.
- Auto-tune. Derives
minKey/bias/softnessfrom the footage itself — once on the first frame or continuously as lighting changes — via a tiny sandboxed plugin system you can extend.
Straight from jsDelivr, no install or build step — it mirrors this repo directly:
<script type="module">
import { ChromaKeyVideo } from 'https://cdn.jsdelivr.net/gh/kaltura/chroma-key-video/src/chromakey.js';
</script>Pin a version for production (@v1.2.0 instead of unpinned) so a new release can't change behavior under you.
<script type="module">
import { ChromaKeyVideo } from './src/chromakey.js';
const player = new ChromaKeyVideo('avatar-greenscreen.mp4', {
videoAttributes: { muted: true, loop: true, autoplay: true, playsInline: true, crossOrigin: 'anonymous' },
});
player.mount(document.querySelector('#stage'));
player.play();
</script>Or as a custom element (style-isolated via closed shadow DOM):
<script type="module">
import { defineChromaKeyVideoElement } from './src/chromakey.js';
defineChromaKeyVideoElement();
</script>
<chroma-key-video src="avatar-greenscreen.mp4" autoplay loop muted edge-dissolve></chroma-key-video>The output canvas behaves like an <img>. Give it a CSS size, or let it default to the video's aspect ratio, and position it anywhere. The render buffer follows the canvas's layout box × devicePixelRatio, capped by maxPixelRatio.
source |
Behavior |
|---|---|
string URL |
Library creates a <video> (configured by options.videoAttributes). Cross-origin URLs need CORS headers — frames are read into a canvas. |
HTMLVideoElement |
Used as-is; you keep control of playback and lifecycle. |
MediaStream |
Webcam, canvas.captureStream(), WebRTC — attached via srcObject. |
| Option | Default | Live? | Description |
|---|---|---|---|
channel |
'green' |
no | Key channel: 'green' or 'blue'. |
minKey |
36 |
yes | Floor (0-255) the key channel must exceed to key. |
bias |
0.96 |
yes | Ratio the key channel must beat each other channel by. |
softness |
28 |
yes | Width of the partial-alpha edge ramp. Higher = softer. |
spill |
0.45 |
yes | Base strength of key-color cast removal on non-keyed edge pixels; ramps to full removal for strong casts. |
autoTune |
false |
no | Fit minKey/bias/softness to the footage: true (once, on the first frame) or 'adaptive' (continuous). |
edgeDissolve |
false |
yes | Enable blur + vignette + fade edge treatment. |
fadeTop |
0.05 |
yes | Top alpha fade, as a fraction of height (edgeDissolve only). |
fadeBottom |
0.18 |
yes | Bottom alpha fade fraction (edgeDissolve only). |
blurStrength |
0.85 |
yes | Blurred-copy mix near the bottom (edgeDissolve only). |
desaturate |
0.35 |
yes | Desaturation toward luma near the bottom (edgeDissolve only). |
cssFade |
true |
yes | Redundant CSS mask-image bottom fade on the canvas (edgeDissolve only). |
maxPixelRatio |
2 |
yes | Cap on devicePixelRatio for the render buffer. |
forceCanvas2D |
false |
no | Force the CPU backend (testing / diagnostics). |
maxCPUPixels |
262144 |
yes | Pixel budget for the CPU fallback (frames downscale to fit). |
videoAttributes |
see source | no | Attributes for a library-created <video> (URL/stream sources). |
| Member | Description |
|---|---|
.canvas |
The transparent output <canvas>. Style/place it yourself or use mount(). |
.video |
The underlying <video> element. |
.backend |
'webgl' | 'canvas2d' | null (before first frame). |
.frameCount |
Frames rendered so far. |
.isDestroyed |
true after destroy(). |
.mount(parent) |
Append the canvas to parent. Returns this. |
.update(partial) |
Change options live; applies next frame (immediately when paused). |
.play() / .pause() |
Control the underlying video. |
.renderFrame() |
Render the current frame once (e.g. after seeking while paused). |
.autoTune(opts?) |
Analyze the current frame and fit minKey/bias/softness. Returns {ok, params, backgroundFraction, applied}. |
.sampleFrame(opts?) |
Downscaled ImageData of the current source frame (or null). |
.use(plugin) |
Attach a plugin (see Plugins). Returns this. |
.unuse(name) |
Detach a plugin by name. Returns true if it was attached. |
.destroy({removeCanvas}) |
Stop and release everything (plugins detach first). Last instance releases the shared GL context. |
ChromaKeyVideo.isWebGLAvailable() |
Static capability check. |
| Event | detail |
When |
|---|---|---|
backend |
'webgl' | 'canvas2d' |
Backend decided or changed (e.g. context loss). |
started |
— | First frame rendered. |
error |
underlying error | Video load failure, tainted canvas (missing CORS), etc. |
autotune |
the autoTune() result object |
Every auto-tune run (manual, option, or plugin). |
pluginerror |
{plugin, error} |
A plugin hook threw; the plugin was detached, the player keeps running. |
defineChromaKeyVideoElement(tagName?) registers the element.
- Source: either a slotted
<video>child or thesrcattribute. A slotted<video>takes precedence oversrcwhen both are present, and can be any source the video element itself supports — file,MediaStream, WebRTC, hls.js/dash.js-attached, etc.:The slotted video is caller-owned:<chroma-key-video auto-tune><video id="my-live-video"></video></chroma-key-video>
autoplay/loop/muted/crossoriginhave no effect on it (configure it directly), and it's never paused or cleared bydestroy()/disconnection. Swapping the slotted<video>at runtime rebuilds the player against the new source. - Attributes:
src,autoplay,loop,muted,channel,min-key,bias,softness,spill,edge-dissolve,auto-tune(empty = once,"adaptive"= continuous),fade-top,fade-bottom,max-pixel-ratio. - Numeric attributes update live.
- The underlying player is exposed as
element.player.
minKey, bias, and softness depend on the footage: how saturated the screen is, how evenly it's lit, how much the codec drifted the color. Auto-tune measures that instead of guessing.
- Downsamples the current frame (~9K pixels, ~3 ms per run including the GPU readback).
- Finds the key-dominant background population.
- Derives the three parameters from its percentiles.
spill is left alone — it's a taste setting.
Three ways to use it:
// 1. One-shot, whenever you like:
player.autoTune(); // → {ok, params, backgroundFraction, applied}
// 2. At construction — tune once on the first frame:
new ChromaKeyVideo(src, { autoTune: true });
// 3. Continuous — re-tune as the video plays (lighting changes, scene cuts):
new ChromaKeyVideo(src, { autoTune: 'adaptive' });
// or with control over cadence and smoothing:
import { autoTunePlugin } from './src/chromakey.js';
player.use(autoTunePlugin({ adaptive: true, interval: 1500, smoothing: 0.5 }));Adaptive runs blend into the current values using an exponential moving average (smoothing = weight of the old value). They skip the update entirely when the change is below visibility thresholds. A static scene converges, then costs only the analysis — no re-renders.
Every run fires an autotune event with the result. {ok: false, reason} reports one of:
'not-ready''unreadable'— tainted canvas'no-background'— under 2% of pixels look like a key screen; current settings are left untouched
The plugin system exists so optional behavior (like auto-tune) stays out of the render path until you ask for it — and so your extensions can't break the player.
player.use({
name: 'fps-probe',
attach(ctx) { ctx.every(1000, () => console.log(ctx.getOptions())); },
frame(ctx, info) { /* runs after each rendered frame: {frameCount, backend, width, height} */ },
detach(ctx) { /* cleanup beyond timers/listeners, which are automatic */ },
});
player.unuse('fps-probe');Plugins never touch the player instance. They get a frozen ctx facade:
ctx member |
Description |
|---|---|
video, canvas, backend, isDestroyed |
Read-only accessors to the live values. |
getOptions() |
Copy of the current options. |
update(partial), autoTune(opts), sampleFrame(opts), requestRender() |
The player's public surface. |
every(ms, fn) |
Managed interval — auto-cleared on detach/destroy. Returns a cancel function. |
on(type, fn, opts) |
Managed player-event listener — auto-removed on detach/destroy. Returns an off function. |
emit(type, detail) |
Dispatch a CustomEvent on the player (namespace your own event types). |
Isolation and cost:
- A throw in any hook detaches that plugin, fires
pluginerror, and leaves the player and other plugins running. - Timers and listeners a plugin registered through
ctxare cleaned up automatically on detach and ondestroy(). - With no plugins attached the per-frame overhead is a single
Map.sizecheck — zero allocation, zero calls. - Plugins are plain functions running with the page's privileges: only attach code you trust, same as any script you include.
Each pixel is classified by how much the key channel dominates the other two, not by distance from a fixed key color:
sat = (maxChannel - minChannel) / maxChannel
dom = key - max(otherA, otherB)
keyed when: key is the max channel, key > minKey, key > otherA*bias,
key > otherB*bias, sat > 0.08, dom > 2
alpha = 1 - clamp((dom - 2) / max(8, softness*0.55) + (sat - 0.08)*1.8, 0, 1)
Keyed pixels are fully despilled: the key channel is clamped to the max of the other two. Partially keyed edge pixels then composite without a green halo.
Pixels that fail the keying gate but still carry a key cast (dom > 4, key > 40) get partial spill removal instead:
dom * min(max(1, spill), spill * (1 + dom/32))is subtracted from the key channel.- Faint casts are reduced by the
spillfraction — a taste setting. - The removal fraction ramps up to a full clamp as the cast grows.
This ramp is for mixed hair/screen pixels — strands thin enough that a pixel is part hair, part screen. They're too dark to pass the keying gate but still carry a strong cast; a constant removal fraction would leave them visibly green.
The trade-off is standard for keyers: a genuinely green foreground object gets desaturated toward its other channels.
With edgeDissolve, the keyed layer also runs through:
- A separable 9-tap Gaussian blur, mixed in by a bottom-anchored vignette with desaturation toward luma.
- Explicit top/bottom alpha fade ramps.
- A redundant CSS mask fade.
video frame ──▶ shared WebGL engine (one hidden context for the whole page)
├─ key pass (per-channel compiled program)
├─ [edgeDissolve] horizontal blur ─▶ composite (vertical blur
│ inline + vignette + desaturate + fades)
└─ blit ──▶ per-instance 2D display canvas (the .canvas you see)
WebGL unavailable / context lost ──▶ Canvas2D fallback (identical per-pixel math,
capped resolution, no blur/vignette)
- Render loop uses
requestVideoFrameCallback(once per presented frame, idle while paused/hidden) with arequestAnimationFramefallback. - Paused/seeked frames render via
loadeddata/seekedlisteners;ResizeObserverre-renders on layout changes. - On
webglcontextlostthe CPU fallback takes over; on restore, programs recompile and instances return to the GPU automatically.
Everything evergreen: Chrome/Edge, Firefox, Safari 15.4+ (including iOS). The library needs <canvas> and uses WebGL 1 when present. Devices without WebGL fall back to the CPU path automatically.
npm install
npm test # e2e + real-footage suite (22 tests)
npm run bench # performance benchmark (Chromium) -> bench-results.json
npm run bench:all # benchmark on Chromium + Firefox + WebKit
npm run check # syntax check + bundle size budget gateE2E suite (Playwright, headless Chromium with SwiftShader). Generates synthetic green/blue-screen test patterns via canvas.captureStream() and renders them through the library. Asserts on output pixels:
- Transparency, ramp alpha, spill values.
- WebGL↔Canvas2D parity, 12 concurrent instances, edge fades, orientation, destroy semantics.
- The custom element and the encoded-URL source path.
- Plugin lifecycle and error isolation.
- One-shot and adaptive auto-tune, including convergence and the
auto-tuneelement attribute.
Real-footage suite (test/footage.spec.js). Runs three short 720p h264 clips of presenters over a green screen, committed in test/assets/ so CI stays offline:
- Two studio talking-head clips, plus a stress clip with long wind-blown hair — loose flying strands and green bounce light on the skin, the classic hard keying case. Sourced from Pixabay under the Pixabay Content License.
- Both backends are exercised.
- Real footage carries codec noise, uneven lighting, hair detail, and soft shadows the synthetic patterns can't, so assertions are statistical: after seeking to a fixed frame and auto-tuning, background margins are fully transparent, the face and body are fully opaque, transparent/opaque population fractions are in range, and the fraction of visible pixels keeping a strong green cast stays under a per-clip ceiling — 0.5% for the studio clips, 0.05% for the hair stress clip.
Benchmark (test/bench.spec.js, measurement logic in test/bench-core.js). Runs on a synthetic 720p source by default, so it stays offline.
- Measures per-frame render cost (WebGL key pass, edgeDissolve, Canvas2D fallback),
autoTune()cost, sustained fps for 1×720p and 12 concurrent players, and main-thread jank. BENCH_SRC=test/assets/greenscreen-hair.mp4 npm run benchruns it on real footage instead.- Fails only on order-of-magnitude regressions — ceilings are sized for SwiftShader.
- Absolute numbers land in
bench-results{-firefox,-webkit}{-footage}.jsonand, on GitHub Actions, the job summary. npm run bench:allruns the same suite on Chromium, Firefox, and WebKit — one Playwright project per engine.
Size gate (scripts/check-size.mjs). Minifies the whole library with esbuild and enforces budgets: 24 KB minified, 8 KB gzip. Raising a budget is a deliberate edit in the same commit as the feature that needs it.
.github/workflows/ci.yml runs all of the above on every push and PR: syntax check → size gate → e2e tests → benchmark (results uploaded as an artifact).
Open the bench page on any device — phone, tablet, laptop. Hit Run benchmark.
- Runs the exact same measurement suite as CI: synthetic pattern and/or the real hair footage.
- Takes ~30 seconds per source.
Then hit Share results. This opens a prefilled GitHub issue with your numbers as JSON.
- A bot validates the submission and adds your device to DEVICES.md, the community leaderboard.
- The issue closes automatically.
- No account data beyond what your browser exposes — user agent, GPU renderer string, core count — is collected.
All rows below key a 1280x720 buffer.
- "Key ms" / "+Dissolve ms" / "CPU ms" are per-frame render cost.
- "fps x12" is the per-player range with 12 concurrent instances running at once.
Across engines, same machine (M3 Pro, macOS), synthetic source, npm run bench:all:
| Engine | GPU / backend | Key ms | +Dissolve ms | CPU ms | autoTune ms | fps x1 | fps x12 | Jank /120 |
|---|---|---|---|---|---|---|---|---|
| Firefox | Apple M1, or similar (real GPU) | 4.56 | 4.48 | 8.27 | 0.40 | 17.4 | 17.5 each | 1 |
| WebKit | Apple GPU (real GPU) | 3.14 | 3.37 | 2.00 | 0.95 | 27.5 | 18.5-19 each | 1 |
| Chromium (headless) | ANGLE/SwiftShader (software) | 13.6 | 32.0 | 2.21 | 0.86 | 30.4 | 20 each | 0 |
Headless Chromium has no real GPU attached — it falls back to the SwiftShader software rasterizer. That's why its key/dissolve costs run higher than Firefox and WebKit's real-GPU numbers above. On real hardware, Chromium is otherwise the fastest engine (see the next table).
Across environments, real hair-footage clip vs. the synthetic source, showing why CI's regression ceilings are set generously:
| Environment | GPU | Key ms | +Dissolve ms | CPU ms | autoTune ms | fps x1 | fps x12 | Jank /120 |
|---|---|---|---|---|---|---|---|---|
| Real Chrome, real GPU (M3 Pro, footage) | ANGLE Metal, Apple M3 Pro | 1.04 | 1.59 | 5.89 | 4.05 | 24 | 24-24.5 | 0 (worst 18.6ms) |
| Headless Chromium, SwiftShader (M3 Pro, footage) | ANGLE/SwiftShader (software) | 10.96 | 29.66 | 3.91 | 6.67 | 24 | 17.5-24.5 | 73 (worst 51.6ms) |
| GitHub Actions hosted runner, SwiftShader (2-core, synthetic) | ANGLE/SwiftShader (software) | 61.06 | 153.48 | 5.05 | 3.20 | 9.5 | 11 each | 120 (worst 100.1ms) |
A real device with real GPU access renders 10-50x faster than the weakest CI runner. That's why test/bench.spec.js's gates are sized off the hosted-runner numbers, not the local ones. They only need to catch genuine regressions, not runner-to-runner variance.
More devices, browsers, and real footage vs. synthetic comparisons accumulate in DEVICES.md as people run the bench page and share results.
Live at kaltura.github.io/chroma-key-video/demo, or locally:
npm run serve
# open http://localhost:4173/demo/The demo includes:
- An animated synthetic green-screen presenter.
- Live tuning sliders and an edge-dissolve toggle.
- An Auto-tune button with an adaptive checkbox — the sliders follow the derived values.
- A URL input for your own footage.
MIT