Binary frame format

The wire format for Hypercolor's binary WebSocket frames: tag bytes, header layouts, and the preview, spectrum, zone, and screen-zones codecs.

The daemon streams high-frequency data over the same /api/v1/ws socket as the JSON control channel, but as binary WebSocket messages instead of text. Every binary frame opens with one or two header bytes that tell a decoder exactly what it is holding before it reads a single byte of payload. This page is the byte-level contract for those frames.

The format is owned by one crate: hypercolor-leptos-ext::ws (feature ws-core, pure Rust with no Leptos or WASM dependency). The daemon’s encoders conform to it, the web UI and the TUI both decode with it, and the round-trip is tested in daemon/src/api/ws/tests.rs. There is no second copy of these layouts anywhere in the codebase, and you should never hand-roll one. If you are building a non-Rust client, mirror the bytes documented here exactly.

For the JSON control channel, the subprotocol token (hypercolor-v1), and how you subscribe to the topics that produce these frames, see the WebSocket protocol reference.

All multi-byte integers and floats are little-endian. Floats are IEEE-754 f32. Direct frames derive payload length from their header and WebSocket message boundary. Publications larger than one message use the 0x0F chunk envelope, which carries explicit total length, offset, and chunk-count fields.

#Two framing conventions ⚡

Hypercolor uses two binary framing conventions on the wire, and the first byte tells you which one you are looking at. Do not assume a uniform header.

The streaming data frames (preview canvases, the audio spectrum, zone previews, and screen zones) use a single tag byte at offset 0. That tag byte is the channel identity. There is no schema byte; these codecs version their layout through the tag space itself and through fixed header lengths.

The preview transport control frames use a tag byte at offset 0 and schema 1 at offset 1. The chunk envelope (0x0F) carries routing, publication, and reassembly metadata before one slice of a larger encoded preview, and the cancellation frame (0x10) retires a publication a client may still be reassembling. Both validate the schema byte before touching the body.

Direct streaming frames do not carry a schema byte. Preview transport control frames do. A decoder that blindly skips two bytes on a spectrum frame will read its timestamp_ms one byte short. Branch on the first byte first, then apply the right convention.

#Tag byte map

Every binary frame is identified by its first byte. These are the load-bearing magic numbers, taken straight from the source constants.

TagFrameConventionSource constant
0x01LED color framessingle byteled_frame
0x02Audio spectrumsingle byteSPECTRUM_FRAME_TAG
0x03Preview: render canvassingle bytePreviewFrameChannel::Canvas
0x05Preview: screen-capture canvassingle bytePreviewFrameChannel::ScreenCanvas
0x06Preview: web viewport canvassingle bytePreviewFrameChannel::WebViewportCanvas
0x07Display previewtag + identity lengthDISPLAY_PREVIEW_FRAME_TAG
0x08Zone previewsingle byteZONE_PREVIEW_FRAME_TAG
0x09Screen zones (ambilight grid)single byteSCREEN_ZONES_FRAME_TAG
0x0AAddressed interactive previewtag + identity lengthINTERACTIVE_PREVIEW_FRAME_TAG
0x0BWide passive previewsingle byteWIDE_PREVIEW_FRAME_TAG
0x0CWide zone previewsingle byteWIDE_ZONE_PREVIEW_FRAME_TAG
0x0DWide interactive previewtag + identity lengthWIDE_INTERACTIVE_PREVIEW_FRAME_TAG
0x0EWide screen zonessingle byteWIDE_SCREEN_ZONES_FRAME_TAG
0x0FPreview chunk envelopetag + schemaPREVIEW_CHUNK_FRAME_TAG
0x10Preview publication cancellationtag + schemaPREVIEW_CANCEL_FRAME_TAG
0x11Extended screen zonessingle byteEXTENDED_SCREEN_ZONES_FRAME_TAG
0x12Wide display previewtag + identity lengthWIDE_DISPLAY_PREVIEW_FRAME_TAG

The LED color frame at 0x01 has an 11-byte fixed header:

Byte(s)  Field
0        tag = 0x01
1-4      frame_number (u32 LE)
5-8      timestamp_ms (u32 LE)
9-10     zone_count (u16 LE)

Each zone then carries a u16 LE UTF-8 id length, the id bytes, a u16 LE LED count, and led_count * 3 RGB bytes.

0x04 is intentionally unused in the current tag set. Treat any unknown tag as a frame you should skip rather than reject the connection; the tag space is designed to grow.

#Passive preview frame (0x03, 0x05, 0x06)

A passive preview frame carries one rendered image: the composed render canvas, the screen capture the ambilight pipeline sees, or the web viewport. All three channels share a single 14-byte header (PREVIEW_FRAME_HEADER_LEN = 14) and differ only by their tag byte. This layout is used whenever both dimensions fit u16; larger surfaces use 0x0B.

Display preview is not in this family. It is keyed by device, so its frames name the device they came from and use the identity-prefixed layout documented below.

offset  size  field
0       1     tag (0x03 | 0x05 | 0x06)
1       4     frame_number  u32
5       4     timestamp_ms  u32
9       2     width         u16
11      2     height        u16
13      1     format        u8  (0=Rgb, 1=Rgba, 2=Jpeg)
14      ..    payload

The format byte selects the payload encoding through PreviewPixelFormat:

ValueFormatBytes per pixelPayload length
0Rgb3width * height * 3
1Rgba4width * height * 4
2Jpegn/aruns to end of message

For the raw formats (Rgb, Rgba) the payload is tightly packed, row-major, top-left origin, and its length is fully determined by width, height, and the per-pixel byte count. For Jpeg there is no fixed length; the payload is a complete JPEG image that runs from offset 14 to the end of the direct publication.

Native Rust clients holding the message as bytes::Bytes can decode with PreviewFrame::decode_bytes, which slices the payload as a refcounted view instead of copying it. Browser clients decode straight from a js_sys::ArrayBuffer via PreviewFrameView::decode_array_buffer and read pixels with rgba_at or pull the whole frame with one boundary crossing through to_rgba_vec.

The default render canvas is 640×480 but is configurable, so never hardcode dimensions, so always read width and height from the header. The canvas can resize live, and the next frame’s header will simply carry the new size.

#Display preview frame (0x07)

One display device’s output frame, always JPEG. display_preview is keyed by device, so a connection following several displays receives several interleaved streams; the device id in the header is how a client routes them. The fixed prefix is 15 bytes (DISPLAY_PREVIEW_FRAME_PREFIX_LEN = 15), followed by the UTF-8 device id and then the payload.

offset  size  field
0       1     tag (0x07)
1       1     device_id length  u8 (1..=128)
2       4     frame_number      u32
6       4     timestamp_ms      u32
10      2     width             u16
12      2     height            u16
14      1     format            u8 (0 = Rgb, 1 = Rgba, 2 = Jpeg)
15      N     device_id         UTF-8, N = device_id length
15+N    ..    payload

The layout carries the full format vocabulary, which is what lets one decoder serve display and interactive previews alike; the daemon only ever writes JPEG on this tag today.

The identity is validated on both sides: non-empty, at most DISPLAY_PREVIEW_ID_MAX_BYTES (128) bytes, and free of control characters. The wide form under 0x12 widens both dimensions to u32 and moves the device id to offset 19. Interactive preview (0x0A / 0x0D) uses the same layout with a preview id in place of the device id, so one decoder serves both.

#Legacy zone preview frame (0x08)

A zone preview is a preview canvas scoped to one zone of one scene. Scenes are whole-rig configurations; zones are flexible partitions of the canvas within a scene. The frame carries both identifiers so a client subscribed to several zones can route each frame without ambiguity. The header is 46 bytes (ZONE_PREVIEW_FRAME_HEADER_LEN = 46).

offset  size  field
0       1     tag (0x08)
1       4     frame_number  u32
5       4     timestamp_ms  u32
9       16    scene_id      [u8; 16]   (UUID bytes)
25      16    zone_id       [u8; 16]   (UUID bytes)
41      2     width         u16
43      2     height        u16
45      1     format        u8  (0=Rgb, 1=Rgba, 2=Jpeg)
46      ..    payload

The scene_id and zone_id are raw 16-byte UUIDs, written in the same byte order they appear in their canonical form. The format byte and the payload follow the exact same rules as the preview frame above. The browser decoder is ZonePreviewFrameView::decode_array_buffer.

Note the field order difference from the basic preview frame: in a zone preview the frame_number and timestamp_ms come before the two UUIDs, and width/height land at offsets 41 and 43, not 9 and 11. The two layouts are not interchangeable; branch on the tag and apply the matching offsets.

For the REST and concurrency side of zones (the routes, If-Match revisions, and ZoneOutcome::Stale) see the Studio zone documentation. This page covers only the preview wire format.

#Legacy screen zones frame (0x09)

The screen zones frame is the ambilight grid: the smoothed, color-tuned per-sector colors extracted from screen capture, exactly as screen-reactive effects consume them. The payload is a row-major RGB grid, grid_cols * grid_rows * 3 bytes. The header is 19 bytes (SCREEN_ZONES_FRAME_HEADER_LEN = 19).

offset  size  field
0       1     tag (0x09)
1       4     frame_number   u32
5       4     timestamp_ms   u32
9       2     source_width   u16
11      2     source_height  u16
13      1     grid_cols      u8
14      1     grid_rows      u8
15      1     letterbox_top  u8
16      1     letterbox_bottom u8
17      1     letterbox_left u8
18      1     letterbox_right u8
19      ..    payload (grid_cols * grid_rows * 3 bytes, row-major RGB)

source_width and source_height describe the captured display the grid was sampled from. The four letterbox bytes are bars expressed in grid units (top, bottom, left, right) so a client can mask the inactive border sectors when a 16:9 source is letterboxed into a different aspect. To read one sector’s color, the decoder offers ScreenZonesFrame::zone_rgb(row, col), which computes (row * grid_cols + col) * 3 and returns the three bytes, or None if the coordinate is out of range.

#Wide preview frames (0x0B through 0x0E, and 0x12)

Wide layouts are additive. They keep legacy tags byte-exact for existing clients and replace only dimension fields with u32 when an axis exceeds u16::MAX.

TagFrameWide header change
0x0BPassive previewbyte 1 is the original channel tag; dimensions are at offsets 10 and 14; payload starts at 19
0x0CZone previewdimensions are at offsets 41 and 45; payload starts at 50
0x0DInteractive previewdimensions are at offsets 10 and 14; preview id starts at 19
0x0EScreen zonessource dimensions are at offsets 9 and 13; grid metadata starts at 17; payload starts at 23
0x12Display previewdimensions are at offsets 10 and 14; device id starts at 19

There is no fixed axis ceiling below u32::MAX. The daemon admits a requested surface using checked pixel and byte arithmetic, with a 512 MiB publication resource budget. Passive width and height may both be zero to select source size; if exactly one is zero, the daemon preserves the source aspect ratio. Interactive previews require both axes to be nonzero.

#Extended screen zones frame (0x11)

Screen zones widen in two steps rather than one. Its grid and letterbox fields are u8 in the legacy layout, so a grid can outgrow 255 sectors on an axis while the source dimensions still fit u16. Tag 0x0E widens only the source dimensions; tag 0x11 widens everything, so grid_cols, grid_rows, and the four letterbox bars each become u32 and the header is 41 bytes (EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN = 41).

offset  size  field
0       1     tag (0x11)
1       4     frame_number   u32
5       4     timestamp_ms   u32
9       4     source_width   u32
13      4     source_height  u32
17      4     grid_cols      u32
21      4     grid_rows      u32
25      16    letterbox      4 × u32 (top, bottom, left, right)
41      ..    payload (grid_cols * grid_rows * 3 bytes, row-major RGB)

The encoder picks the narrowest layout each frame fits, so a client must accept all three tags on the screen_zones channel and read the grid from whichever header it received.

#Preview chunk envelope (0x0F)

Preview publications larger than the 1 MiB per-message budget are sent as ordered chunks without resizing or truncation.

offset  size  field
0       1     tag = 0x0F
1       1     schema = 1
2       1     stream_kind (0=passive, 1=zone, 2=interactive, 3=screen_zones, 4=display)
3       1     channel tag
4       1     pixel format
5       2     stream_identity_len u16
7       8     publication_id u64
15      4     frame_number u32
19      4     timestamp_ms u32
23      4     width u32
27      4     height u32
31      8     total_encoded_bytes u64
39      8     chunk_offset u64
47      4     chunk_index u32
51      4     chunk_count u32
55      N     stream identity
55+N    ..    chunk payload

The stream identity is empty for passive and screen-zone streams, 32 raw UUID bytes for a zone stream, the UTF-8 preview id for an interactive stream, and the UTF-8 device id for a display stream. Clients reassemble by stream and publication id, require contiguous ordered chunks with stable metadata, and bound both per-publication and per-connection memory. Reassembly state is connection-scoped and must be cleared on reconnect.

The envelope carries no payload format of its own. Reassembled bytes are one of the ordinary frames documented above, identified by the channel byte at offset 3, so a client concatenates the chunks and hands the result to the same decoder it would have used for a direct publication.

#Preview cancellation (0x10)

The daemon retires a publication a client may still be holding partial chunks for by sending a cancellation frame. The header is 14 bytes plus the stream identity (PREVIEW_CANCEL_FIXED_HEADER_LEN = 14, PREVIEW_CANCEL_SCHEMA = 1).

offset  size  field
0       1     tag = 0x10
1       1     schema = 1
2       1     stream_kind (0=passive, 1=zone, 2=interactive, 3=screen_zones, 4=display)
3       1     channel tag
4       2     stream_identity_len u16
6       8     publication_id u64
14      N     stream identity

The stream_kind, channel, and identity fields address the stream exactly as they do in the chunk envelope. On receipt, drop any partial reassembly buffer held for that publication id and release its memory; the publication will not be completed.

#Spectrum frame (0x02)

The spectrum frame is one audio analysis snapshot: the overall level, the three band energies, beat detection, and the full FFT bin array. The header is 27 bytes (SPECTRUM_FRAME_HEADER_LEN = 27), followed by bin_count little-endian f32 values.

offset  size  field
0       1     tag (0x02)
1       4     timestamp_ms     u32
5       1     bin_count        u8
6       4     level            f32
10      4     bass             f32
14      4     mid              f32
18      4     treble           f32
22      1     beat             u8  (0 | 1)
23      4     beat_confidence  f32
27      ..    bins             bin_count × f32

Because bin_count is a u8, the wire format carries at most 255 bins; the encoder truncates anything longer. The level, bass, mid, and treble values are the normalized energies that audio-reactive effects key off. beat is a hard 0/1 flag and beat_confidence is its f32 certainty.

BPM is deliberately not in the binary spectrum frame. Clients that need tempo read it from the JSON metrics channel instead. The binary frame stays lean so it can stream at audio rate without dragging slow-moving fields along on every packet.

#Decode errors

Every codec on this page reports failures through PreviewFrameDecodeError.

VariantMeaning
TooShortmessage shorter than the fixed header
UnknownChanneltag byte is not a known channel
UnknownPixelFormatformat byte is not 0/1/2
DimensionsOverflowwidth × height × bpp overflows usize
PayloadTooShortheader valid but payload truncated

A robust client validates the header before allocating for the payload. Every codec here checks its declared length against the actual message length, so a truncated or malformed frame fails cleanly instead of reading past the buffer.

#Schema bytes

Every schema byte on the wire today is 1, on both preview transport control frames. The byte exists so a control frame’s layout can be revised without burning a new tag; a decoder rejects a schema value it does not recognize rather than guessing at the body.

The preview transport’s own v1 and v2 capability strings are a separate mechanism, and they never reach the binary wire. They negotiate the memory budgets a receiver will honor, in JSON, on the control channel; see the WebSocket protocol reference. A decoder reading the bytes on this page never needs to know which one was negotiated.

#Where this lives

ConcernFile
Tag constants and public re-exportsws/mod.rs
Preview, zone-preview, screen-zones codecsws/preview.rs
Spectrum codecws/spectrum.rs
Codec round-trip testscrates/hypercolor-leptos-ext/tests/ws_preview_frame_tests.rs
Daemon conformance testsdaemon/src/api/ws/tests.rs
Machine-checked frame manifestprotocol/websocket-v1.json

All source paths are relative to crates/hypercolor-leptos-ext/src/; the two test paths and the manifest are repo-relative. The manifest lists every tag, layout name, and transport budget, and the daemon test suite asserts it against the code, so a layout change that skips the manifest fails CI. When any layout on this page changes, the source constant and its round-trip test change with it; read those, never this prose, when the bytes have to be exactly right.