English | 简体中文
Play HEVC/H.265 video in browsers without native support. No plugin. No install. No server changes.
A from-scratch HEVC decoder written in C++17, compiled to WebAssembly, with drop-in plugins for dash.js, Shaka Player and hls.js. Transcodes HEVC to H.264 in real-time, client-side, via WebCodecs inside a Web Worker. Works on Chrome, Edge, and Firefox where WebCodecs H.264 encoding is available.
Decodes all 147 bitstreams of the official HEVC v1 conformance suite bit-exactly — details.
1080p @ 60fps. 283 KB WASM, ~103 KB gzip-compressed. Zero dependencies. No special server headers required. Compute-aware quality control caps the player's ABR ceiling when the device can't transcode at real-time, so the buffer never starves — automatic with the dash.js plugin, one call away with hls.js and Shaka.
Built in 8 days by one developer, assisted by AI — read the story.
- dash.js — hevc.js ships as an official HEVC playback sample in dash.js (5.2.1+).
- Shaka Player — the Shaka plugin was built after a Shaka Player maintainer requested it.
See ROADMAP.md for what's next.
No bundler, no file copying — load everything from public CDNs:
<video id="player" controls></video>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script type="module">
import { attachHevcSupport } from 'https://esm.sh/@hevcjs/dashjs-plugin@1';
const player = dashjs.MediaPlayer().create();
await attachHevcSupport(player, {
wasmUrl: 'https://unpkg.com/@hevcjs/core@1/dist/wasm/hevc-decode.js',
wasmBinaryUrl: 'https://unpkg.com/@hevcjs/core@1/dist/wasm/hevc-decode.wasm',
workerUrl: 'https://unpkg.com/@hevcjs/core@1/dist/transcode-worker.js',
});
player.initialize(document.querySelector('#player'), 'https://example.com/manifest.mpd', true);
</script>Cross-origin Worker and WASM loading are handled automatically. For production, prefer the npm install below (self-hosted assets, version pinning).
npm install @hevcjs/dashjs-plugin # dash.js
npm install @hevcjs/shaka-plugin # Shaka Player
npm install @hevcjs/hlsjs-plugin # hls.js
npm install @hevcjs/mediabunny-plugin # Mediabunny (decoding, no player)The plugin relies on 3 static files from @hevcjs/core (installed as a transitive dependency) that must be served by your web server:
transcode-worker.js— Web Worker (IIFE, standalone)wasm/hevc-decode.js— Emscripten glue codewasm/hevc-decode.wasm— WASM binary (283 KB, ~103 KB gzipped)
Copy them from node_modules/@hevcjs/core/dist/ to your public directory:
cp node_modules/@hevcjs/core/dist/transcode-worker.js public/
cp node_modules/@hevcjs/core/dist/wasm/hevc-decode.js public/
cp node_modules/@hevcjs/core/dist/wasm/hevc-decode.wasm public/Pass the path to the copied worker via workerUrl in the example below. The worker loads hevc-decode.js / .wasm from the same directory automatically.
import dashjs from 'dashjs';
import { attachHevcSupport } from '@hevcjs/dashjs-plugin';
const player = dashjs.MediaPlayer().create();
attachHevcSupport(player, { workerUrl: './transcode-worker.js' });
player.initialize(videoElement, 'https://example.com/manifest.mpd', true);import shaka from 'shaka-player';
import { registerHevcTransmuxer } from '@hevcjs/shaka-plugin';
const handle = registerHevcTransmuxer(shaka, { workerUrl: './transcode-worker.js' });
const player = new shaka.Player();
await player.attach(videoElement);
handle.attachComputeAware(player); // wire compute-aware ABR (on by default)
await player.load('https://example.com/manifest.mpd');registerHevcTransmuxer registers a Shaka Transmuxer for hev1/hvc1, so Shaka handles MIME routing natively. To force transcoding even where HEVC is supported natively, use Shaka's built-in player.configure({ mediaSource: { forceTransmux: true } }).
handle.attachComputeAware(player) is what enables compute-aware quality control (see below). It's a separate call because Shaka's player instance doesn't exist at register time. Pass adaptiveCompute: false in the register config to opt out.
import Hls from 'hls.js';
import { attachHevcSupport } from '@hevcjs/hlsjs-plugin';
// Before `new Hls()` — hls.js filters levels against
// MediaSource.isTypeSupported at manifest parse time.
const handle = await attachHevcSupport({ workerUrl: './transcode-worker.js' });
// Classic MediaSource is required by the transcoding path. iPhone Safari only
// has ManagedMediaSource: pinning classic MSE there would leave hls.js with no
// MediaSource at all and playback would fail, so keep its default.
const hls = new Hls({
...(typeof MediaSource !== 'undefined' ? { preferManagedMediaSource: false } : {}),
});
handle.attachComputeAware(hls); // wire compute-aware ABR (on by default)
hls.attachMedia(videoElement);
hls.loadSource('https://example.com/playlist.m3u8');No player instance needed to attach: hls.js keeps HEVC levels in its ladder as long as the (patched) MediaSource.isTypeSupported accepts them. Supported today: fMP4 HLS with video-only, demuxed-audio, or muxed audio+video renditions. For muxed A/V (single audiovideo track) the HEVC video is transcoded and the AAC audio is passed through, re-muxed into one combined segment (main-thread path; AAC only). Validated end-to-end with a muxed test stream. See the plugin README for details.
import { registerHevcDecoder } from '@hevcjs/mediabunny-plugin';
// decode-worker.js (from this package), hevc-decode.js and .wasm served side by side
await registerHevcDecoder({ workerUrl: '/decode-worker.js' });Not a player plugin: it registers a custom video decoder with Mediabunny, so reading, converting and extracting frames from HEVC files works where WebCodecs cannot decode HEVC. No transcoding to H.264 here, the decoded frames go to Mediabunny as they are. Profiles the browser decodes natively are left to WebCodecs. Decoding runs in a Web Worker, or on the page's thread without workerUrl. See the plugin README.
-
MSE intercept — Patches
MediaSource.addSourceBuffer()before the player initializes. When the player creates an HEVC SourceBuffer, we return a proxy that accepts HEVC data but feeds H.264 to the real SourceBuffer. -
Worker pipeline — All heavy work runs in a Web Worker:
- Demux: mp4box.js extracts raw HEVC NAL units from fMP4 segments
- Decode: WASM decoder produces YUV frames (spec-compliant, pixel-perfect)
- Encode: WebCodecs
VideoEncodercompresses to H.264 - Mux: Custom fMP4 muxer wraps H.264 in ISO BMFF with correct timestamps
-
Transparent to the player — The proxy reports
updating, firesupdatestart/updateendevents, and returns realbufferedranges. The player's buffer management, ABR logic, and seek handling work unmodified.
Tradeoff: the software fallback introduces 2-3s of startup latency on the first segment (vs instant playback with native hardware decode). Once buffered, playback is smooth. When native HEVC is available, hevc.js detects it and does nothing.
WASM-based HEVC transcoding is significantly more expensive than native decode. On low-end CPUs at 1080p, transcoding a 2s segment can take 6s — the buffer drains at -4s per segment and playback freezes. Bandwidth-based ABR (Shaka, dash.js) doesn't see this: the network is fine, so it keeps the top variant.
The plugins fix this by piping per-segment transcode speedX (segDurMs / wallClockMs) onto a small in-process bus that the plugins consume. A player-agnostic decider observes the rolling speed, applies hysteresis, and asks the host player to narrow its variant ceiling via the player's own public ABR settings:
- Shaka →
player.configure({ abr: { restrictions: { maxHeight, maxBandwidth } } }) - dash.js →
player.updateSettings({ streaming: { abr: { maxBitrate: { video } } } }) - hls.js →
hls.autoLevelCapping
The host ABR is never replaced — we only narrow the menu it picks from. Once headroom returns (sustained speedX > 1.3×), the ceiling lifts back up.
On by default. Pass adaptiveCompute: false to opt out:
// Tune (defaults: measureWindow 2, lowerAfter 1, raiseAfter 6, targetSpeedX 1.3)
registerHevcTransmuxer(shaka, { adaptiveCompute: { targetSpeedX: 1.5 } });
// or
attachHevcSupport(player, { adaptiveCompute: false });Telemetry hook for diagnostics — fires on every segment, not just cap changes:
attachHevcSupport(player, {
adaptiveCompute: {
onObservation: (stat, avg, capIdx, reason) => {
console.log(`speedX=${stat.speedX} avg=${avg} cap=${capIdx} (${reason})`);
},
},
});subscribeSegmentStat is also exported from all three plugins if you want the raw perf bus.
hevc.js transcodes HEVC to H.264 client-side. This requires two things from the browser: WebAssembly (to run the HEVC decoder) and WebCodecs VideoEncoder with H.264 support (to re-encode the decoded frames). When native HEVC is available, the plugin detects it and does nothing — zero overhead.
Detection strategy: MediaSource.isTypeSupported() can lie (Firefox on Windows reports HEVC support even without the HEVC Video Extension installed). hevc.js verifies native support by actually creating a SourceBuffer — if that fails, it falls back to transcoding. On iPhone Safari (iOS 17.1+), only ManagedMediaSource exists — hevc.js detects native HEVC through it and defers to the browser (the transcoding path requires classic MediaSource). For the same reason, forceTranscode is ignored there for the dash.js plugin and playback stays native; Shaka's forceTransmux still works since Shaka feeds its own SourceBuffers.
Each browser has its own decode path on Windows, with different dependencies:
- Chrome 107+ (Windows) uses
D3D11VideoDecoder→ D3D11VA (DXVA) directly. No Microsoft extension required. Requires a GPU with HEVC hardware decoder (Intel Skylake 2015+, NVIDIA Maxwell 2nd gen / GTX 960 2015+, AMD Fiji / R9 Fury 2015+). No software fallback — if the GPU cannot decode HEVC, Chrome will not play it. Chrome < 130 also caps at 1920×1088 @ 30fps. - Edge (Windows) uses
VDAVideoDecoder→ MFT (Media Foundation). Requires the Microsoft HEVC Video Extension (~$1 on the Store). Without it, no HEVC regardless of GPU. - Firefox 133+ (Windows) also uses MFT and has the same dependency on the Microsoft HEVC Video Extension.
- Firefox 137+ (Linux) decodes HEVC natively — hardware via VA-API, software via the system ffmpeg. Availability follows the distro's ffmpeg build; the SourceBuffer probe falls back to transcoding when it doesn't.
- macOS (Safari / Chrome / Edge / Firefox) decode HEVC natively via VideoToolbox. No extension.
| Browser + OS + condition | Native HEVC | hevc.js activates? | Transcoding works? | Why |
|---|---|---|---|---|
| Safari 13+ (macOS/iOS) | Yes (VideoToolbox) | No — native | — | Hardware decode via macOS/iOS (iPhone: detected via ManagedMediaSource, iOS 17.1+) |
| Chrome/Edge/Firefox (Mac) | Yes (VideoToolbox) | No — native | — | Native decode via macOS |
| Chrome 107+ (Win, HEVC-capable GPU) | Yes (D3D11VA) | No — native | — | Direct GPU decode, no extension needed |
| Chrome 107+ (Win, GPU without HEVC) | No | Yes | Yes | Chrome has no software HEVC fallback |
| Edge (Win, with HEVC Video Extension) | Yes (MFT) | No — native | — | Requires Microsoft HEVC Video Extension |
| Edge (Win, no extension) | No | Yes | Yes | MFT without extension: no decoder |
| Firefox 133+ (Win, with HEVC Video Extension) | Yes (MFT) | No — native | — | Requires Microsoft extension |
| Firefox 133+ (Win, no extension) | Reported but fake | Yes | Yes | SourceBuffer probe catches the false positive, falls back to transcoding |
| Chrome/Edge 94–106 | No | Yes | Yes | HEVC not yet shipped in browser, WebCodecs H.264 encoder available |
| Chrome/Edge < 94 | No | No | No | No WebCodecs — serve AVC content directly |
| Chrome (Linux, VAAPI enabled) | Variable | Sometimes | Yes | Depends on driver and GPU |
| Chrome (Linux, no VAAPI) | No | Yes | Yes | Software H.264 encode via WebCodecs |
| Firefox 137+ (Linux, ffmpeg with HEVC) | Yes (VA-API / ffmpeg) | No — native | — | Native since Firefox 137 — hardware VA-API or system ffmpeg |
| Firefox < 137 (Linux) | No | Yes | Depends | Requires a working H.264 encoder via WebCodecs — fails on headless/VM setups |
Requirements (supported by all modern browsers):
- WebAssembly + Web Workers
- Secure Context (HTTPS or localhost) — WebCodecs is not available on plain HTTP
- WebCodecs VideoEncoder with H.264 support — this is the main limiting factor
No Cross-Origin-Embedder-Policy or Cross-Origin-Opener-Policy headers needed — the WASM decoder is single-threaded and doesn't use SharedArrayBuffer. Works on any static file server.
libde265 exists, is mature, and works. So why write another HEVC decoder?
This implementation targets a different niche on three axes:
- Size — 283 KB WASM (~103 KB gzipped) vs ~2 MB for libde265 compiled to WASM. 7× smaller — which matters when shipping to a browser, a microVM, or a sandboxed runtime.
- Modernity & license — C++17 throughout (
std::optional,std::shared_ptr,std::array,constexpr), zero dependencies, MIT-licensed (vs LGPL for libde265 — relevant for static linking in commercial products). - Spec traceability — function names mirror ITU-T H.265 section numbers, and
docs/cross-reference.mdmaps every spec section to its source file and test. Useful if you want to understand HEVC, not just decode it (universities, codec research, contributors).
This is not a libde265 replacement — libde265 is battle-tested in production (GStreamer, VLC, libheif). For embedding in browsers, microVMs, and sandboxed environments where binary size, license, or readability matter more than raw throughput, this decoder is a viable alternative.
For a side-by-side of the four ways to play HEVC in a browser — hevc.js, libde265.js, ffmpeg.wasm and h265web.js — see HEVC in the browser: four ways, compared (WASM size, license, MSE streaming, COOP/COEP requirements).
#include "wasm/hevc_api.h"
HEVCDecoder* dec = hevc_decoder_create();
hevc_decoder_decode(dec, data, size);
int count = hevc_decoder_get_frame_count(dec);
for (int i = 0; i < count; i++) {
HEVCFrame frame;
hevc_decoder_get_frame(dec, i, &frame);
// frame.y / frame.cb / frame.cr — YUV planes (uint16_t*)
// frame.width / frame.height — luma dimensions
// frame.bit_depth — 8 or 10
}
hevc_decoder_destroy(dec);// Lifecycle
HEVCDecoder* hevc_decoder_create(void);
void hevc_decoder_destroy(HEVCDecoder* dec);
// Decode a complete HEVC bitstream (Annex B format)
int hevc_decoder_decode(HEVCDecoder* dec, const uint8_t* data, size_t size);
// Incremental decode (feed NAL units progressively)
int hevc_decoder_feed(HEVCDecoder* dec, const uint8_t* data, size_t size);
int hevc_decoder_drain(HEVCDecoder* dec);
// Access decoded frames (display order)
int hevc_decoder_get_frame_count(HEVCDecoder* dec);
int hevc_decoder_get_frame(HEVCDecoder* dec, int index, HEVCFrame* frame);
// Pictures decoded with PicOutputFlag = 0 (H.265 §C.3.1): decoded, usable as
// references, never output. Read the count, then copy them out as (cvs_id,
// poc) pairs — `out` holds 2*max int32_t. The copy clears the list, and fails
// rather than truncate if `max` is too small.
int hevc_decoder_get_suppressed_picture_count(HEVCDecoder* dec);
int hevc_decoder_take_suppressed_pictures(HEVCDecoder* dec, int32_t* out, int max);| HEVCFrame field | Type | Description |
|---|---|---|
y, cb, cr |
const uint16_t* |
YUV plane pointers |
width, height |
int |
Luma dimensions (conformance window applied) |
stride_y, stride_c |
int |
Plane strides in samples |
bit_depth |
int |
8 or 10 |
poc |
int |
Picture Order Count — display order within one CVS |
cvs_id |
int |
Coded video sequence; POC restarts at each IDR/BLA, so display order is (cvs_id, poc) |
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
cd build && ctest --output-on-failure # 160 testspnpm build:wasm
# Output: build-wasm/hevc-decode.js + hevc-decode.wasm (283 KB, ~103 KB gzipped),
# copied into packages/core/wasm/Uses a local Emscripten SDK
when emcmake is on the PATH — source its emsdk_env.sh first — and otherwise
runs emscripten/emsdk:6.0.8, the version CI pins, through Docker. No local SDK
install needed on that route. WASM_BUILDER=docker forces the container even
when an SDK is available.
Decode speed on an Apple M4 Pro (10 performance + 4 efficiency cores), the 1-thread and WASM columns measured on 2026-10-25, the WPP-threads column and the transcode on 2026-10-21:
| Stream | Native C++, 1 thread | Native C++, WPP threads | WASM (Node 24, V8) |
|---|---|---|---|
1080p, SAO, WPP — bbb1080_50f.265 |
167 fps | 256 fps | 135 fps |
4K, SAO, WPP — bbb4k_25f.265 |
35 fps | 47 fps | 30 fps |
4K, SAO, no WPP — bbb4k_singleslice.265 |
47 fps | — | 40 fps |
1080p transcode (HEVC decode + H.264 encode, in Chromium through Playwright 1.62, Shaka demo, ABR 480p/720p/1080p preset, 1080p variant (SAO) active in every run): 1.8–2.0x real time.
The WASM build has no threads, so the single-threaded native column is the one to compare it with: WASM runs at 81% of native at 1080p and 86% at 4K. The native decoder parallelises WPP rows on a pool sized to the core count, which is the third column; a stream without WPP decodes on one thread either way.
How these were measured, to reproduce or re-run:
- Streams:
tests/conformance/fixtures/anddemo/, all with SAO enabled — check withhevc-decode <file> --dump-headers. - Native: Release build, Apple clang 17.0.0,
hevc-decode <file> -o /dev/null, median of 5 runs of the decode time it prints. The 1-thread column runs withHEVC_THREADS=1. The WPP-threads column predates the SIMD and entropy-decoding work of #232 and understates the current decoder: it was not re-measured because the machine was loaded on 2026-10-25, which skews a multi-threaded run but not a single-threaded one —mainbefore that work, measured alongside as a control, landed within 4% of its 2026-10-21 single-threaded figures. - WASM: the shipped
packages/core/wasm/hevc-decode.wasm(emsdk 6.0.8),node tools/bench_wasm.cjs <file> packages/core/wasmon Node 24.4.1, median of 5 runs of its best-of-3. V8 is Chrome's engine, but this is not a measurement inside Chrome. - Transcode:
speedX(segment duration over transcode time) printed by the unthrottled case oftests/e2e/compute-cap.spec.ts, three runs, six segments of 2s.
Implemented per ITU-T H.265 (v8, 08/2021) — 716 pages, transcribed directly from the spec.
All 147 bitstreams of the JCT-VC HEVC v1 conformance suite decode bit-exactly. For each stream, the MD5 of the decoded YUV equals the MD5 of the HM reference decoder's output, shipped with the stream by the JCT-VC. The suite covers the Main and Main 10 profiles: coding tools, slices and dependent slice segments, tiles, WPP, PCM, scaling lists, long-term references, picture output order. Measured on 2026-10-25, main at 8417151, Release build. For comparison, ffmpeg 9.0.2 run through the same script matches 143: it does not support TSUNEQBD_A_MAIN10_Technicolor_2, whose luma and chroma bit depths differ, and its output differs from the reference on SAODBLK_A_MainConcept_4, SAODBLK_B_MainConcept_4 and VPSSPSPPS_A_MainConcept_1.
Anyone can reproduce it — the script downloads the suite from the ITU site, about 140 MB:
tools/run_conformance.sh build/hevc-decode /tmp/hevc-conformance25 of these streams are committed in tests/conformance/fixtures/ and run as oracle tests by CI on every pull request; the WASM build is checked to decode them exactly like the native one.
Each spec section is mapped 1:1 to its source file and test in docs/cross-reference.md.
| Feature | Status |
|---|---|
| CABAC arithmetic decoding (§9.3) | Complete |
| 35 intra prediction modes (§8.4) | Complete |
| Inter prediction — merge, AMVP, TMVP (§8.5) | Complete |
| 8-tap luma / 4-tap chroma interpolation (§8.5.3) | Complete |
| Weighted prediction — default + explicit (§8.5.3.3) | Complete |
| Inverse transform — DCT 4-32, DST 4 (§8.6) | Complete |
| Scaling lists (§8.6.3) | Complete |
| Deblocking filter (§8.7.2) | Complete |
| SAO — edge + band offset (§8.7.3) | Complete |
| 10-bit decoding (Main 10 profile) | Complete |
| Multi-slice (dependent + independent) | Complete |
| Tiles | Parsed + sequential decode |
| WPP (Wavefront Parallel Processing) | Complete |
Output order — PicOutputFlag (§C.3.1), RASL suppression (§8.1) |
Complete |
hevc.js/
├── src/ C++17 HEVC decoder (ITU-T H.265 spec-compliant)
│ ├── bitstream/ Annex B parsing, NAL units, RBSP, Exp-Golomb
│ ├── syntax/ VPS, SPS, PPS, slice header parsing
│ ├── decoding/ CABAC, coding tree, intra/inter prediction, transform
│ ├── filters/ Deblocking filter, SAO
│ ├── common/ Types, Picture buffer, thread pool
│ └── wasm/ C API, Emscripten bindings
│
├── packages/
│ ├── core/ @hevcjs/core — WASM decoder + transcoding pipeline
│ ├── dashjs-plugin/ @hevcjs/dashjs-plugin — dash.js plugin
│ ├── shaka-plugin/ @hevcjs/shaka-plugin — Shaka Player plugin
│ ├── hlsjs-plugin/ @hevcjs/hlsjs-plugin — hls.js plugin
│ └── mediabunny-plugin/ @hevcjs/mediabunny-plugin — Mediabunny custom decoder
│
├── demo/ Browser demos (DASH + HLS)
└── tests/ Unit tests + oracle tests (MD5 of the decoded YUV)
Live demos — try each plugin in your browser:
| Demo | Description |
|---|---|
| Decoder | Raw WASM decoder — drop a .265 file, frame-by-frame playback |
| dash.js | HEVC DASH streams via dash.js + WASM transcoding |
| Shaka | HEVC DASH streams via Shaka Player + WASM transcoding |
| hls.js | HEVC HLS streams (ABR + audio) via hls.js + WASM transcoding |
| Mediabunny | HEVC files (8 and 10-bit) read and decoded by Mediabunny, hevc.js as its decoder — or drop your own file |
Docs: dash.js plugin · Shaka plugin · hls.js plugin
Each demo includes a "Force transcoding" toggle to bypass native HEVC detection — useful for testing the WASM pipeline on browsers that already support HEVC.
pnpm install
pnpm build:demo # Builds WASM + JS bundles + copies assets
npx serve demo # Open http://localhost:3000Thanks goes to these people (emoji key):
![]() Thibaut Lion 💻 📖 🤔 👀 🚇 |
![]() Marie 🤔 👀 |
MIT — see LICENSE.
HEVC/H.265 may be covered by patents managed by Access Advance and other patent pools. This software is an independent implementation and does not include or grant any patent license. Users are responsible for evaluating patent obligations in their jurisdiction and use case.
Media samples use Big Buck Bunny (CC-BY 3.0, Blender Foundation). See THIRD-PARTY-NOTICES.md for full attribution.



