@hevcjs/hlsjs-plugin

HEVC Plugin for hls.js

Play H.265 HLS streams in every browser. One call before new Hls().

Transparently transcodes HEVC to H.264 client-side via WebAssembly.
When native HEVC is available, the plugin does nothing.

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');
Static assets: copy 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.

1
Runs before new Hls() — hls.js filters levels against MediaSource.isTypeSupported at manifest parse time, so the intercept has to be in place first
2
Probes native HEVC support — by actually creating an HEVC SourceBuffer, not just isTypeSupported. Native support → the plugin does nothing
3
Checks WebCodecs H.264 encoding — before installing anything, so unsupported browsers degrade cleanly
4
Patches isTypeSupported and intercepts addSourceBuffer() — hls.js keeps HEVC levels in its ladder; a Proxy wraps the H.264 SourceBuffer
5
Proxy intercepts appendBuffer() — demux, decode HEVC (WASM), encode H.264 (WebCodecs), mux fMP4, append to real SourceBuffer
6
Strict append progress updateend waits for the first transcoded chunk to land, so hls.js's bufferAppendNoProgress watchdog stays quiet
No player instance needed. Unlike the dash.js plugin, 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):

WebAssembly Web Workers Secure Context (HTTPS) WebCodecs VideoEncoder hls.js 1.7.x (tested)

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.

60fps
1080p decode
WASM, single-thread
236KB
WASM binary
gzipped, zero deps
2-3s
startup latency
first segment transcode
Tradeoff: the first segment takes 2-3s to transcode (vs instant with native hardware decode). Once buffered, playback is smooth. When native HEVC is available, the plugin detects it and does nothing — zero overhead.

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