25 KiB
09 — Hardware Acceleration, Encoding, WebCodecs, GPU Textures
This chapter covers the encoding/decoding/WASM pipeline in the MIT approach. Critical changes from Greenfield:
- No x264enc (GPL-2.0+) — replaced with
vaapih264enc/nvh264enc/openh264enc - No FFmpeg Wasm (GPL-3.0 in Greenfield's build) — replaced with WebCodecs primary + OpenH264 BSD fallback
- Hardware decode enabled (Greenfield disables it with a FIXME)
- Adaptive bitrate (consumes
EncoderFeedbackdata Greenfield produces but doesn't use)
What's reused (MIT)
Browser side
| Component | License | Role |
|---|---|---|
@gfld/compositor-wasm |
MIT | pixman 0.42.2 + xkbcommon 1.5.0 WASM (region math + keymap) |
Vendored C (MIT, used in Python proxy via cffi)
| File | Lines | Role |
|---|---|---|
westfield-dmabuf.c |
~200 | DMA-BUF import via EGL/GBM |
westfield-egl.c |
~900 | EGL zero-copy setup (EGLDisplay, EGLContext, EGLImage) |
wlr_drm.c |
~244 | wl_drm global impl |
wlr_linux_dmabuf_v1.c |
~1,110 | zwp_linux_dmabuf_v1 global impl |
drm_format_set.c |
~239 | DRM format helpers |
What's replaced (proprietary, clean-room)
Browser side
| Component | Replaces | Why |
|---|---|---|
Custom WebCodecsFrameDecoder.ts |
Greenfield's webcodec-buffer-decoder.ts (AGPL) |
Clean-room; enables hardware decode (Greenfield disables with FIXME) |
Custom OpenH264FrameDecoder.ts |
Greenfield's wasm-buffer-decoder.ts + H264NALDecoder.worker.ts (AGPL) + @gfld/compositor-ffmpeg-h264 (GPL) |
Clean-room; uses OpenH264 BSD Wasm instead of FFmpeg GPL Wasm |
Custom YUVA2RGBA / RGBXandA2RGBA shaders |
Greenfield's (AGPL) | Clean-room; WebGL2 (Greenfield uses WebGL1) |
Custom Renderer / Scene / SceneShader |
Greenfield's (AGPL) | Clean-room; OffscreenCanvas + Web Workers (Greenfield is main-thread) |
Python side
| Component | Replaces | Why |
|---|---|---|
Custom gst_encoder.py (~700 LOC) |
Greenfield's proxy-encoding-addon.node (AGPL, 290 lines C) + libproxy-encoding.so (AGPL, ~1400 lines C) |
Pure Python via PyGObject. Same GStreamer, no N-API addon. |
vaapih264enc / nvh264enc / openh264enc |
x264enc (GPL) |
GPL-free H.264 encoding |
Encoder selection (per-frame, deterministic)
Same logic as Greenfield, but without x264enc:
| name | element | when used | split_alpha | coded-size multiple | License |
|---|---|---|---|---|---|
vaapih264 |
vaapih264enc |
>256×256 + ARGB/XRGB/dmabuf, Intel/AMD GPU | true (parallel alpha H.264 stream) | 128 | LGPL + MIT (libva) |
nvh264 |
nvh264enc |
same, NVIDIA GPU | true | 128 | BSD plugin + proprietary NVENC blob |
openh264 |
openh264enc |
same, no GPU (CPU fallback) | true | 128 | BSD-2-Clause (libopenh264) |
png |
pngenc |
≤256×256 + ARGB/XRGB/dmabuf | false (PNG has native alpha) | 16 | LGPL |
Rule: pixel_count ≤ 256×256 → PNG; otherwise the configured H.264
encoder (selected by --encoder=vaapih264|nvh264|openh264).
NOTE: This means the default encoder is hardware-accelerated (vaapih264 on Intel/AMD, nvh264 on NVIDIA). Greenfield defaults to x264 (software, GPL).
Canonical H.264 pipeline (Python equivalent)
Same GStreamer pipeline as Greenfield, ported to PyGObject:
# gst_encoder.py (clean-room, ~700 LOC)
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstGL", "1.0")
gi.require_version("GstEGL", "1.0") # if available
from gi.repository import Gst, GstGL, GLib
class H264Encoder:
def __init__(self, egl_display_ptr, egl_context_ptr, encoder_type="vaapih264"):
# Create GLDisplay + GLContext from existing EGL context (zero-copy)
self.gl_display = GstGL.GLDisplayEGL.new_with_egl_display(egl_display_ptr)
self.gl_context = GstGL.GLContext.new_wrapped(
self.gl_display,
egl_context_ptr,
GstGL.GLPlatform.EGL,
GstGL.GLLAPI.GLES2,
)
# Encoder pipeline
encoder_element = {
"vaapih264": "vaapih264enc",
"nvh264": "nvh264enc",
"openh264": "openh264enc",
}[encoder_type]
self.pipeline = Gst.parse_launch(
f"appsrc name=src ! "
f"glupload ! glcolorconvert ! "
f"glshader name=scale ! " # pads to macroblock multiple
f"glcolorconvert ! video/x-raw(memory:GLMemory),format=NV12 ! "
f"gldownload ! queue ! "
f"{encoder_element} tune=zerolatency bitrate=12000 vbv-buf-capacity=160 "
f"bframes=0 rc-lookahead=0 sliced-threads byte-stream=true ! "
f"video/x-h264,profile=high,stream-format=byte-stream,alignment=au ! "
f"appsink name=sink"
)
# Wire up appsink callback (zero-copy external Buffers)
sink = self.pipeline.get_by_name("sink")
sink.emit("set-property", "emit-signals", True)
sink.connect("new-sample", self._on_new_sample)
# Alpha pipeline (separate, for transparent windows)
self.alpha_pipeline = self._build_alpha_pipeline(encoder_element)
# GLib MainLoop in separate thread (GStreamer requires it)
self.glib_loop = GLib.MainLoop()
self.glib_context = GLib.MainContext.new()
GLib.MainContext.push_thread_default(self.glib_context)
import threading
self.glib_thread = threading.Thread(target=self.glib_loop.run, daemon=True)
self.glib_thread.start()
def encode_frame(self, buffer_id, content_serial, creation_serial, dma_buf_fd, w, h):
# Push buffer into appsrc (zero-copy via GstBuffer wrapping the dmabuf)
# ... marshalled from asyncio via GLib.idle_add
def _on_new_sample(self, appsink):
sample = appsink.emit("pull-sample")
buffer = sample.get_buffer()
# Marshal back to asyncio
asyncio.run_coroutine_threadsafe(
self._deliver_frame(buffer, self.current_buffer_id),
self.asyncio_loop,
)
Bitrates (same as Greenfield)
- Opaque: 12000 (vaapih264 / nvh264 / openh264)
- Alpha: 1200 (10% of opaque)
Adaptive bitrate (NEW — Greenfield lacks)
Consume EncoderFeedback data (refreshInterval + decode duration):
# adaptive_bitrate.py (clean-room, ~150 LOC)
class AdaptiveBitrate:
def __init__(self, encoder):
self.encoder = encoder
def on_feedback(self, refresh_interval_ms, avg_decode_ms):
# If decode is taking too long, reduce bitrate
if avg_decode_ms > refresh_interval_ms * 0.8:
new_bitrate = max(self.current_bitrate * 0.7, MIN_BITRATE)
self.encoder.set_property("bitrate", new_bitrate)
# If decode is fast and we have headroom, increase bitrate
elif avg_decode_ms < refresh_interval_ms * 0.3:
new_bitrate = min(self.current_bitrate * 1.3, MAX_BITRATE)
self.encoder.set_property("bitrate", new_bitrate)
Wire format (REUSED from Greenfield)
Same binary format as Greenfield (see ../greenfield-analysis/09-hardware-accel.md):
struct packed props {
u32 buffer_id, creation_serial, content_serial,
u32 frame_encoding_type (0=h264, 1=png),
u32 width, height, coded_width, coded_height
}
u32 opaque_length
u8 opaque[opaque_length] // H.264 AU or PNG blob
u32 alpha_length // 0 if no alpha split
u8 alpha[alpha_length] // H.264 AU of alpha-only stream
Browser decode (clean-room TS)
Preferred path: WebCodecs VideoDecoder (with HW decode enabled)
// WebCodecsFrameDecoder.ts (clean-room, ~250 LOC)
export class WebCodecsFrameDecoder implements FrameDecoder {
private opaqueDecoders: Map<SurfaceId, VideoDecoder> = new Map();
private alphaDecoders: Map<SurfaceId, VideoDecoder> = new Map();
async isSupported(): Promise<boolean> {
const config = {
codec: 'avc1.64001f',
optimizeForLatency: true,
// NEW: enable hardware acceleration (Greenfield disables this)
hardwareAcceleration: 'prefer-hardware',
};
const result = await VideoDecoder.isConfigSupported(config);
return result.supported;
}
async decode(surface: Surface, encodedFrame: EncodedFrame): Promise<DecodedFrame> {
let opaqueDecoder = this.opaqueDecoders.get(surface.id);
if (!opaqueDecoder) {
opaqueDecoder = new VideoDecoder({
output: (frame) => this.onDecoded(surface, frame),
error: (e) => this.onError(surface, e),
});
opaqueDecoder.configure({
codec: 'avc1.64001f',
optimizeForLatency: true,
hardwareAcceleration: 'prefer-hardware', // NEW
});
this.opaqueDecoders.set(surface.id, opaqueDecoder);
}
const chunk = new EncodedVideoChunk({
type: encodedFrame.isKeyframe ? 'key' : 'delta',
data: encodedFrame.opaque,
timestamp: encodedFrame.timestamp,
});
opaqueDecoder.decode(chunk);
// Alpha path (if present)
if (encodedFrame.alpha) {
// Same pattern for alphaDecoder
}
}
}
Key difference from Greenfield: We use hardwareAcceleration: 'prefer-hardware' (Greenfield uses 'prefer-software' with a FIXME
about "frame offset is wrong"). This needs testing, but should give
significant CPU savings.
Fallback path: OpenH264 BSD Wasm (NOT FFmpeg — FFmpeg is GPL)
// OpenH264FrameDecoder.ts (clean-room, ~250 LOC)
// Uses OpenH264 BSD-2-Clause Wasm build (Cisco's binary release or self-built)
// Available from: https://github.com/cisco/openh264 (BSD-2-Clause)
OpenH264 is BSD-2-Clause — fully proprietary-compatible. Less featureful than FFmpeg (no VP9/AV1), but sufficient for H.264 fallback.
WebGL2 upload (clean-room TS, modernized)
WebGL2 with desynchronized:true + OffscreenCanvas in Web Worker.
Per Scene (canvas/output), three GL helpers (same as Greenfield, but WebGL2 instead of WebGL1):
YUVA2RGBA
FBO pass: uploads Y/U/V/alphaY as 4× LUMINANCE textures (WebGL1
compatible) OR 1× R8UI texture array (WebGL2, more efficient),
renders into the surface's RGBA texture via YUVA2RGBAShader (BT.601
coefficients).
RGBXandA2RGBA
FBO pass for the DualPlaneRGBA* WebCodecs outputs (opaque + alpha
planes composited via RGBandA2RGBAShader).
SceneShader
Final pass: draws every view's RGBA texture to the canvas with
gl.blendFunc(ONE, ONE_MINUS_SRC_ALPHA).
NEW: WebGL2 texStorage2D for immutable textures (faster upload).
NEW: VideoFrame directly as a WebGL texture source (zero-copy from
WebCodecs to GPU, no copyTo needed).
End-to-end pipeline
┌──────────────────── NATIVE CLIENT PROCESS ────────────────────┐
│ App renders via GTK/Qt/raw EGL into │
│ ┌────────────────────────────────────┐ │
│ │ wl_shm buffer (ARGB8888/XRGB8888)│ ─── or ─────────┐ │
│ │ zwp_linux_dmabuf_v1 (DMA-BUF) │ │ │
│ │ wl_drm create_prime_buffer │ │ │
│ └────────────────────────────────────┘ │ │
└────────────────────────┬──────────────────────────────────┼────┘
│ Wayland socket (UNIX domain) │
▼ │
┌────────────────────── PYTHON PROXY (aiohttp + pywayland + GStreamer)─┐
│ pywayland (Apache-2.0) │
│ └─ Display → Client → Resource (buffer) │
│ └─ display.init_shm(), init_drm("/dev/dri/renderD128") │
│ └─ Vendored westfield-egl.c (MIT, via cffi): │
│ EGLDevice + EGLDisplay + EGLContext │
│ │
│ gst_encoder.py (~700 LOC Python, clean-room) │
│ ┌─ frame_buffer {type: SHM|DMA, w, h, fmt} │
│ │ │
│ │ GLib MainLoop in separate thread (GLib.idle_add) │
│ │ │ │
│ │ ▼ │
│ │ gst_parse_launch: │
│ │ ┌─ Encoder selection (per-frame): │
│ │ │ pixel_count ≤ 256×256 ─► PNG pipeline │
│ │ │ > 256×256 + ARGB/XRGB/dmabuf ─► configured h264 │
│ │ │ │
│ │ │ H.264 pipelines (parallel, NO x264 — GPL): │
│ │ │ opaque: appsrc → glupload → glcolorconvert → │
│ │ │ glshader(scale→coded_size) → │
│ │ │ glcolorconvert(NV12) → gldownload → │
│ │ │ queue → vaapih264enc|nvh264enc|openh264enc │
│ │ │ → byte-stream AU → appsink │
│ │ │ alpha: same source, fragment shader │
│ │ │ `vec4(pix.a,pix.a,pix.a,0.)` → │
│ │ │ second H.264 stream │
│ │ │ │
│ │ │ PNG pipeline (single): │
│ │ │ appsrc → glupload → glcolorconvert → glshader → │
│ │ │ RGBA → gldownload → pngenc → appsink │
│ │ │ │
│ │ └─ asyncio.run_coroutine_threadsafe for cross-thread │
│ │ │
│ └─ surface_buffer_encoding.py │
│ └─ frame_data_channel.send(buffer) │
│ │
│ channel.py: │
│ ARQChannel (kcp PyPI package, MIT, over WebSocket) │
│ SimpleChannel (raw WebSocket, for FEEDBACK only) │
│ ▲ │
│ │ frame_feedback.py: 16-bit rAF interval + 16-bit avg │
│ │ decode duration → used to pace wl_frame.done AND │
│ │ drive adaptive_bitrate.py (NEW vs Greenfield) │
└───────┼───────────────────────────────────────────────────────┘
│ 4 logical channels per client: PROTOCOL / FRAME / XWM / FEEDBACK
▼
┌────────────────────── BROWSER (Custom Compositor TS, clean-room) ──────┐
│ WebSocket → ReconnectingWebSocket (vendored MIT) │
│ ↓ │
│ channel.ts: ARQChannel.input(kcp) → onMessage │
│ ↓ │
│ RemoteAppLauncher.setupFrameDataChannel → │
│ BufferStream.deliverContentToBufferStream (parse serials) │
│ ↓ │
│ StreamingBuffer.getContents(surface, serial) │
│ ↓ │
│ Session.frameDecoder.decode(surface, encodedFrame) │
│ │ │
│ ├─ PNG path (both decoders): │
│ │ new Blob([opaque], {type:'image/png'}) → │
│ │ createImageBitmap(blob, sx, sy, w, h) // crop padding │
│ │ → SinglePlane { bitmap: ImageBitmap } │
│ │ │
│ └─ H.264 path: │
│ (preferred) WebCodecs VideoDecoder │
│ config: avc1.64001f, prefer-hardware (NEW!), │
│ optimizeForLatency │
│ opaqueDecoder + alphaDecoder per surface │
│ EncodedVideoChunk {type:key|delta, data:nal} │
│ → VideoFrame.output → │
│ if I420: copyTo → DualPlaneYUVAArrayBuffer │
│ else: keep VideoFrame → DualPlaneRGBAVideoFrame │
│ (fallback) OpenH264 BSD Wasm in 2 Web Workers │
│ (NOT FFmpeg — GPL contamination) │
│ worker.postMessage(nal, [nal.buffer]) // transferable │
│ OpenH264._decode → I420 planes │
│ postMessage back with [y.buffer, u.buffer, v.buffer] │
│ → DualPlaneYUVASplitBuffer │
│ │
│ Renderer.render() (rAF-driven, in Web Worker via OffscreenCanvas)│
│ ↓ │
│ Scene.render(viewStack) │
│ ├─ Scene['image/png'] → texImage2D(ImageBitmap) │
│ ├─ Scene['image/bitmap'] (web-bitmapbuf path) → texImage2D │
│ └─ Scene['video/h264']: │
│ YUVA2RGBA.convertYUVASplitBufferInto / │
│ convertYUVAArrayBufferInto: │
│ Y/U/V/alphaY → 4× LUMINANCE textures (WebGL1 compat) │
│ OR 1× R8UI texture array (WebGL2, more efficient) │
│ bind FBO → renderState.texture (RGBA) as color attach │
│ YUVA2RGBAShader (BT.601) → drawArrays → RGBA in tex │
│ or RGBXandA2RGBA.convertInto (WebCodecs RGBA): │
│ VideoFrame/ImageBitmap → texImage2D directly │
│ ↓ │
│ SceneShader.draw(): drawArrays with surface's RGBA texture, │
│ blend gl.ONE / gl.ONE_MINUS_SRC_ALPHA, desynchronized canvas │
│ ↓ │
│ Browser composites canvas into the page │
└─────────────────────────────────────────────────────────────────┘
Same-frame web app fast path (web-bitmapbuf, REUSED):
┌───────────────┐ MessagePort.postMessage([view, ...fds], ┌──────────────┐
│ Web app │ ──► [imageBitmap]) ──────────────────────────► │ Compositor │
│ (iframe) │ web_bitmapbuf_factory.create_buffer(id, fd) │ WebConnHandl │
└───────────────┘ → wl_buffer → ImageBitmapBuffer └──────────────┘
(no encoding, no WebSocket, no GStreamer)
Key answers (quick reference)
Why H.264 over PNG?
H.264 at 12 Mbps × 1/60s ≈ 25 KB/frame for 1080p, vs PNG 100 KB – 3 MB. PNG wins for ≤256×256 because H.264 I-frame overhead and alpha-split doubling dominate at small sizes.
WebCodecs?
Yes — preferred path. Hardware decode enabled (Greenfield disables with FIXME). OpenH264 BSD Wasm is the fallback (NOT FFmpeg — GPL).
Trade-offs?
- H.264 = small wire / decode cost
- PNG = medium wire / browser-native fast decode (
createImageBitmap) - raw SHM = not used between proxy and browser — every surface commit is encoded.
Per-surface policy?
No — per-frame deterministic. Only configurable knob is
--encoder=vaapih264|nvh264|openh264. PNG is always automatic fallback.
Cursors?
Always PNG (32×32 ≤ 256×256). Decoded to ImageBitmap and set as the CSS
cursor via setBrowserCursor — never enters the WebGL compositor.
Alpha pre-multiplication?
Implicit in shaders (same as Greenfield). Works acceptably for typical Wayland content.
OffscreenCanvas / Web Workers?
Yes — Render Worker (WebGL2 + OffscreenCanvas) + Decode Worker (WebCodecs VideoDecoder). Greenfield uses only 2 workers for the WASM fallback path; our MIT approach uses Workers for ALL rendering.
Adaptive bitrate (NEW)?
Yes — adaptive_bitrate.py consumes EncoderFeedback data and
adjusts vaapih264enc/nvh264enc/openh264enc bitrate at runtime.
Greenfield produces the feedback data but doesn't use it.
Notable design trade-offs for the MIT approach
- Hardware-accelerated encoding by default (vaapih264 on Intel/AMD, nvh264 on NVIDIA). Greenfield defaults to x264 (software, GPL).
- Hardware-accelerated decoding by default (WebCodecs
prefer-hardware). Greenfield usesprefer-softwarewith a FIXME. - No GPL anywhere in the encoding/decoding pipeline (no x264, no FFmpeg Wasm).
- Adaptive bitrate consumes the feedback data Greenfield ignores.
- OpenH264 BSD Wasm as fallback (Cisco's binary release or self-built). Sufficient for H.264 fallback when WebCodecs is unavailable.
- OffscreenCanvas + Web Workers for all rendering (Greenfield is main-thread).
- WebGL2 instead of WebGL1 (uniform buffer objects, simplified shaders).
- VideoFrame directly as WebGL texture source (zero-copy from
WebCodecs to GPU, no
copyToneeded). - Same alpha-split H.264 approach as Greenfield (doubles cost for transparent windows) — could be replaced with AV1 with native alpha support in the future.
- Future: AV1/VP9 support via WebCodecs (when browser support is widespread) — sidesteps H.264 MPEG-LA patent royalties entirely.
File reference
Browser side (custom, clean-room TS)
| Path | Role |
|---|---|
packages/compositor/src/transport/decoders/WebCodecsFrameDecoder.ts |
WebCodecs VideoDecoder (HW-accelerated) |
packages/compositor/src/transport/decoders/OpenH264FrameDecoder.ts |
OpenH264 BSD Wasm fallback |
packages/compositor/src/transport/decoders/FrameDecoder.ts |
Decoder interface |
packages/compositor/src/transport/EncodedFrame.ts |
Wire format parser for encoded frames |
packages/compositor/src/transport/EncoderFeedback.ts |
Sends 4-byte decode feedback at 1 Hz |
packages/compositor/src/transport/StreamingBuffer.ts |
Per-surface encoded-frame buffer |
packages/compositor/src/transport/BufferStream.ts |
Manages serial → frame promise resolution |
packages/compositor/src/render/Renderer.ts |
4-phase render pipeline (WebGL2 + OffscreenCanvas) |
packages/compositor/src/render/Scene.ts |
One per output, manages view stack + textures |
packages/compositor/src/render/YUVA2RGBA.ts |
YUVA→RGBA FBO pass |
packages/compositor/src/render/YUVA2RGBAShader.ts |
BT.601 YUVA→RGBA shader |
packages/compositor/src/render/RGBXandA2RGBA.ts |
RGB+Alpha → RGBA FBO pass (WebCodecs output) |
Browser side (REUSED MIT)
| Path | Role |
|---|---|
libs/compositor-wasm/src/index.ts |
pixman + xkbcommon WASM (region math + keymap) |
Python side (custom, clean-room)
| Path | Role |
|---|---|
proxy/encoding/gst_encoder.py |
GStreamer H.264/PNG encoder (~700 LOC) |
proxy/encoding/adaptive_bitrate.py |
Adaptive bitrate controller (NEW) |
proxy/surface_buffer_encoding.py |
Encoding pipeline orchestration |
proxy/proxy_buffer.py |
Proxy-side buffer representation |
proxy/frame_feedback.py |
Receives 4-byte decode feedback |
proxy/encoding/glib_thread.py |
GLib MainLoop in separate thread + asyncio marshalling |
Vendored Greenfield C files (MIT, used via cffi)
| Path | Role |
|---|---|
native/westfield-egl.c |
EGL zero-copy setup |
native/westfield-dmabuf.c |
DMA-BUF import via EGL/GBM |
native/wlr_drm.c |
wl_drm global impl |
native/wlr_linux_dmabuf_v1.c |
zwp_linux_dmabuf_v1 global impl |
native/drm_format_set.c |
DRM format helpers |
Reused (LGPL/BSD/MIT)
| Component | Role |
|---|---|
PyGObject (gi.repository.Gst) |
GStreamer Python bindings (LGPL-2.1+) |
| GStreamer core | Media pipeline framework (LGPL-2.0+) |
| gst-plugins-base, gst-plugins-good | PNG, appsrc, appsink, videoconvert (LGPL) |
| gst-vaapi | vaapih264enc for Intel/AMD (LGPL + MIT libva) |
| gst-plugins-bad (BSD subset) | nvh264enc for NVIDIA, openh264enc for CPU fallback |
| libopenh264 | OpenH264 BSD Wasm fallback decoder (BSD-2-Clause) |