Quick start
Install the package, call one function before creating the Hls instance.
$ npm install @hevcjs/hlsjs-plugin hls.js
import Hls from 'hls.js';
import { attachHevcSupport } from '@hevcjs/hlsjs-plugin';
const video = document.querySelector('video');
// Must run BEFORE `new Hls()` — hls.js filters levels against
// MediaSource.isTypeSupported at manifest parse time.
await attachHevcSupport({
workerUrl: '/transcode-worker.js',
wasmUrl: '/hevc-decode.js',
});
const hls = new Hls({
...(typeof MediaSource !== 'undefined' ? { preferManagedMediaSource: false } : {}),
});
hls.attachMedia(video);
hls.loadSource('https://example.com/stream/playlist.m3u8');
transcode-worker.js, hevc-decode.js and hevc-decode.wasm from node_modules/@hevcjs/core/dist/ to your public directory.
<script type="module">
import { attachHevcSupport } from 'https://esm.sh/@hevcjs/hlsjs-plugin@0';
const video = document.querySelector('video');
await attachHevcSupport({
workerUrl: 'https://unpkg.com/@hevcjs/core@1/dist/transcode-worker.js',
wasmUrl: 'https://unpkg.com/@hevcjs/core@1/dist/wasm/hevc-decode.js',
wasmBinaryUrl: 'https://unpkg.com/@hevcjs/core@1/dist/wasm/hevc-decode.wasm',
});
const hls = new Hls({
...(typeof MediaSource !== 'undefined' ? { preferManagedMediaSource: false } : {}),
});
hls.attachMedia(video);
hls.loadSource('https://example.com/stream/playlist.m3u8');
</script>
preferManagedMediaSource: false keeps hls.js on classic MediaSource, which the transcoding path requires. Guard it on typeof MediaSource !== 'undefined': iPhone Safari exposes only ManagedMediaSource, and pinning classic MSE there leaves hls.js without any MediaSource, which breaks playback. Left on its default, iPhone plays HEVC natively.
How it works
The plugin intercepts the MSE pipeline. hls.js never knows HEVC was involved.
new Hls()
— hls.js filters levels against MediaSource.isTypeSupported at manifest parse time, so the intercept has to be in place first
isTypeSupported. Native support → the plugin does nothing
isTypeSupported and intercepts addSourceBuffer()
— hls.js keeps HEVC levels in its ladder; a Proxy wraps the H.264 SourceBuffer
appendBuffer()
— demux, decode HEVC (WASM), encode H.264 (WebCodecs), mux fMP4, append to real SourceBuffer
updateend waits for the first transcoded chunk to land, so hls.js's bufferAppendNoProgress watchdog stays quiet
attachHevcSupport() takes no player argument — hls.js keeps HEVC levels as long as the (patched) isTypeSupported accepts them. Demuxed audio renditions pass through untouched.
Browser compatibility
~94% of browsers play HEVC natively (hardware decode). hevc.js activates only for the ~6% that don't.
| Browser | Native HEVC | hevc.js needed? | Transcoding works? |
|---|---|---|---|
| Safari 13+ (macOS/iOS) | Yes (VideoToolbox) | No — bypassed | — |
| Chrome/Edge/Firefox (Mac) | Yes (VideoToolbox) | No — bypassed | — |
| Chrome 107+ (Win, HEVC-capable GPU) | Yes (D3D11VA) | No — bypassed | — |
| Chrome 107+ (Win, GPU without HEVC) | No | Yes | Yes (WebCodecs H.264) |
| Edge (Win, with HEVC Video Extension) | Yes (MFT) | No — bypassed | — |
| Edge (Win, no extension) | No | Yes | Yes (WebCodecs H.264) |
| Firefox 133+ (Win, with HEVC Video Extension) | Yes (MFT) | No — bypassed | — |
| Firefox 133+ (Win, no extension) | Reported but fake | Yes | Yes (SourceBuffer probe catches false positive) |
| Chrome/Edge 94–106 | No | Yes | Yes (WebCodecs H.264) |
| Chrome/Edge < 94 | No | Yes | No (no WebCodecs) — falls back to AVC |
| Chrome (Linux, VAAPI) | Variable (driver-dependent) | Sometimes | Yes (software encode) |
| Chrome (Linux, no VAAPI) | No | Yes | Yes (software encode) |
| Firefox (Linux) | No | Yes | Depends — needs WebCodecs H.264 encoder |
Other requirements (supported by all modern browsers):
API reference
One function to set up, one function to tear down.
attachHevcSupport(config?)
Probes native HEVC support (by actually creating an HEVC SourceBuffer, not just isTypeSupported), checks WebCodecs H.264 encoding, then installs the MSE intercept.
Returns a Promise resolving to a cleanup() function that reverses all patches.
No player instance is needed — but it must run before new Hls().
const cleanup = await attachHevcSupport({
workerUrl: '/transcode-worker.js', // Web Worker URL
wasmUrl: '/hevc-decode.js', // WASM glue location
wasmBinaryUrl: '/hevc-decode.wasm', // WASM binary location
});
// Then create the player
const hls = new Hls({
...(typeof MediaSource !== 'undefined' ? { preferManagedMediaSource: false } : {}),
});
// Remove all patches when done
cleanup();
Options
workerUrl
string
URL of the Web Worker script for off-main-thread transcoding (recommended). If omitted, transcoding runs on the main thread.
wasmUrl
string
URL of the Emscripten glue script (hevc-decode.js). Auto-detected from the package if omitted.
wasmBinaryUrl
string
URL of the .wasm binary — required when assets live on a different origin than the page (CDN setups). Emscripten otherwise resolves the .wasm relative to the worker's blob: URL and fails.
forceTranscode
boolean
default: false
Transcode even when native HEVC is available. Useful for testing.
logLevel
string
'debug' | 'info' | 'warn' | 'error' | 'silent'.
fps
number
default: 25
Target framerate for H.264 encoding, inherited from the core transcoder config. Should match the source stream.
bitrate
number
H.264 encode bitrate in bits/second, inherited from the core transcoder config. If omitted, WebCodecs chooses automatically.
Compute-aware ABR — handle.attachComputeAware(hls)
On by default. The handle returned by attachHevcSupport exposes attachComputeAware(hls): it observes per-segment transcode speedX and caps hls.autoLevelCapping when the device can't transcode the current level in real time — hls.js's own ABR keeps choosing freely below that ceiling. Pass adaptiveCompute: false at attach time to opt out, or an object to tune the decider. subscribeSegmentStat stays re-exported for custom telemetry.
import { subscribeSegmentStat } from '@hevcjs/hlsjs-plugin';
const handle = await attachHevcSupport({ workerUrl: './transcode-worker.js' });
const hls = new Hls({
...(typeof MediaSource !== 'undefined' ? { preferManagedMediaSource: false } : {}),
});
handle.attachComputeAware(hls); // caps hls.autoLevelCapping under compute pressure
Scope & compatibility
Tested against hls.js 1.7.x. The declared peer range >=1.4.0 is not fully exercised — 1.6.6 changed how hls.js drives SourceBuffer.timestampOffset, and this plugin is designed for the current behavior.
Supported today: HLS fMP4 streams — video-only, demuxed-audio and muxed audio+video renditions. Master playlists with an audio group work — validated end-to-end.
Muxed audio+video fMP4 segments (single audiovideo SourceBuffer): HEVC video transcoded, AAC audio passed through, re-muxed into one combined segment (main-thread path, AAC only). HEVC-in-MPEG-TS is untested.
Compute-aware ABR is wired via handle.attachComputeAware(hls) — on by default, caps hls.autoLevelCapping under compute pressure.
Performance
Real numbers from a single-threaded WebAssembly decoder.
FAQ
Does it work with live streams?
Yes. The plugin intercepts segments as they arrive, so live (low-latency) and VOD streams both work. The only difference is that the first segment takes 2-3 seconds to transcode, which adds to the initial live edge latency.
What happens when native HEVC is available?
Nothing. The plugin probes native HEVC support at startup — by actually creating an HEVC SourceBuffer, not just calling isTypeSupported — and stays dormant when the browser can play HEVC directly. Zero overhead. To transcode anyway (for testing), pass forceTranscode: true.
Do I need special server headers?
No. The WASM decoder is single-threaded and does not use SharedArrayBuffer, so no Cross-Origin-Embedder-Policy or Cross-Origin-Opener-Policy headers are needed. It works on any static file server with HTTPS.
What about muxed audio+video segments?
Supported. For a muxed rendition (single audiovideo track, codecs="hvc1...,mp4a...") the HEVC video is transcoded and the AAC audio is passed through, re-muxed into one combined A/V segment so a single SourceBuffer plays both. The muxed path runs on the main thread (the worker fast path is video-only) and implements AAC pass-through only. Validated end-to-end with a muxed test stream. HEVC in MPEG-TS is untested.
How is this different from the dash.js plugin?
Both share the same @hevcjs/core MSE intercept, HEVC decoder, and H.264 encoder. The difference: the hls.js plugin needs no player instance — but it must be called before new Hls(), because hls.js filters levels against MediaSource.isTypeSupported at manifest parse time. The dash.js plugin instead takes the player instance and registers a capabilities filter.
Get started
Install the package and start playing HEVC in every browser.
npm install @hevcjs/hlsjs-plugin