WHAT TO WEAR

1 · Architecture

Two channels, chosen by one rule: if losing a message is a problem, it rides TCP; if only the freshest sample matters, it rides UDP.

Sessionargs + sidecar files
TCP, once per video
Eventsstart / play / pause / skip / scrub (begin·move·release) / end + heartbeat (1 s default, ?hb= 0.25–30 s)
TCP, reliable
Replies[code][data] back to the app
TCP, one per message
Telemetryzones + poses, 20 Hz
UDP, latest-wins
WorkerReceivesDefault ports
Scriptsession + events (TCP)TCP 9000 · web viewer 9001
Lightingsession + events (TCP) and zone/pose telemetry (UDP = TCP port + 1)TCP 9100 · UDP 9101 · web viewer 9102

Endpoint URLs

In the app's Script / Lighting settings you enter host:port plus optional ?args. The query string is forwarded verbatim to the worker in the session — it is the single config surface for both the worker and the app.

192.168.1.30:9000?key=YOURKEY
192.168.1.30:9100?key=YOURKEY&pose=both&baseline=0.064
ArgRead byMeaning
keyworkerShared secret. Demo workers reject sessions (and web viewers) without the right key.
poseappoff (default, zero pose work) · 1 single-eye 2D poses · both both eyes, so the worker can triangulate 3D depth from disparity.
videoworkerAdded by the app, not typed in the URL: the playing video's file name without extension, percent-encoded (decode it!). Only present when the user enables Send video name in that worker's settings — never assume it; fall back to sidecars. Stem only, never a path.
anything elseworkerForwarded untouched — calibration, mode flags, whatever your worker wants (e.g. baseline, proj).

2 · Wire format

Everything is little-endian and tightly packed (no alignment padding, no type tags). Reference codecs — reader and writer — ship in Python, Java, and Swift and are verified byte-identical.

TypeSizeEncoding
byte / bool1unsigned · 0/1
int16 / int32 / int642 / 4 / 8two's-complement LE
float / double4 / 8IEEE-754 LE
short ASCII1 + Nu8 length (N < 128), then N bytes
long ASCII4 + Nint32 length, then N bytes
short Unicode1 + 2Nu8 length in UTF-16 code units (N < 128), then UTF-16LE
long Unicode4 + 2Nint32 length in code units, then UTF-16LE
Readers never assume the buffer is memory-aligned — multi-byte values are assembled byte-by-byte, so any offset into any buffer is safe (ARM-friendly).

3 · TCP control protocol

Session — once, when a video loads

longASCII  args          // endpoint URL query (+ "&video=<stem>" if the user opted in), e.g. "key=YOURKEY&pose=both"
int32      fileCount     // sidecar files: names starting with the video's stem + "."
-- per file --
shortASCII ext           // everything after "stem." — "srt", "json", or qualified: "roll.funscript"
int32      contentLength
bytes      content

Event — on every transport change + heartbeats

int32  action     // see table below
double position   // seconds into the video
double length     // total duration, seconds
actionNameSent when
0starta video finishes loading
1playplayback resumes
2pauseuser pauses, or the player closes
3skip±15 s skip buttons
4scrubscrubber drag released (final position)
5endclip reaches its end. The player then loops: it seeks back to 0 and keeps playing, without a further event — so treat end as "restarting", not "stopped"
6heartbeatabout once a second by default, also while paused. Carries the true playhead, so use it to correct drift in your own clock — and as a watchdog. Change the cadence with ?hb=<seconds> in the endpoint URL, clamped 0.25–30 (a logger that wants quiet asks for ?hb=30).
7scrubBeginscrubber drag starts (position = playhead at drag start)
8scrubMove~10 Hz live position while the drag is in progress

Workers must ignore (but still reply to) action codes they don't recognize — new ones may be added.

If your worker drives hardware, treat missed heartbeats as a dead player and stop. A dropped Wi-Fi link or a suspended headset leaves the TCP socket looking perfectly healthy — no close arrives — so silence is the only signal you get. Stop after a few missed beats (a 3–5 s timeout at the 1 s default), not after 30.

Reply — worker → app, after every message

int32 code        // your status / command code (403 = unauthorized in the demos)
int32 dataLength
bytes data        // free-form payload back to the app

The reply channel is how a worker talks back. Replies are never matched up with what the app sent, so a worker may push a frame at any moment — which is what makes the control commands below possible on the same connection, with no listening port on the headset.

Control — worker → app, whenever it likes

Send the same frame with one of these codes. The player performs the change and emits the matching playback event, which comes back to every worker — so you learn the result the same way you learn everything else, and never have to track a playhead the player didn't confirm.

Reply codes below 100 are yours; 100 and up are reserved for commands. Demo workers ack an event by echoing its action code, which is safe because action ids stay under 100 — that is a promise, not a coincidence. Don't ack with a code ≥ 100 or the player will read your acknowledgement as an instruction.

codeCommandPayload
100play—
101pause—
102play/pause toggle—
103skipdouble seconds, signed (−10 back, +10 forward)
104seekdouble absolute position in seconds
Control is opt-in per worker. The user has to enable Allow playback control in that endpoint's settings; until then commands are ignored. Two rules once it's on: never answer an event with the command that caused it (the confirmation would loop), and show state from the events, not from your own guess — start and play mean playing, pause and end mean paused. Asking for a state the player is already in is harmless: it re-sends the event, which is how a worker that has drifted gets corrected.

The demo script_handler.py shows the whole loop — its web page has play/pause and ±10 s buttons, and the state it displays is driven purely by the events coming back.


4 · UDP telemetry packet (Lighting worker, ~20 Hz, one datagram each)

int32  seq            // drop stale/reordered datagrams
double timestamp
u8     cols, rows     // zone grid, currently 8×4
-- per zone (row-major, cols×rows) --
u8 r, g, b            // average color, LINEAR RGB (convert for your lights)
u8 motion             // color change vs previous sample
u8 coverage           // fraction of zone NOT keyed out as green (0–255)
-- models --
int32 modelCount      // detected people
-- per model --
u8 eye                // 0 = left/primary, 1 = right (pose=both only)
u8 jointCount
  u8 id · int16 x · int16 y · int16 confidence   // coords/conf ×10000, per-eye normalized, bottom-left origin
  u8 r, g, b                                    // frame color AT the landmark (gamma RGB)
u8 regionCount
  u8 id · int16 x · int16 y · u8 r, g, b        // calculated clothing sample points

Joint ids (0–18)

0–4nose · leftEye · rightEye · leftEar · rightEar
5neck
6–11shoulders · elbows · wrists (L, R)
12–17hips · knees · ankles (L, R)
18root

Clothing region ids

0hat
1hair
2shirt
3pants
4 · 5left shoe · right shoe
3D from two eyes. With ?pose=both the same person arrives once per eye. Pair models across eyes (same vertical position), then depth ∝ horizontal disparity xL − xR. Pass your rig's calibration through the args (e.g. baseline=0.064&proj=equirect) to get metric depth.

5 · Run the demo workers

Two reference workers, with the Python and Java codecs, are packaged as a download. Each worker opens its data port(s) plus a live auto-refreshing web viewer.

Download reference workers (38 KB) Python 3.9+, no third-party packages.

unzip vrvideoplayer-workers.zip && cd vrvideoplayer-workers
python3 script_handler.py      # TCP 9000 · viewer http://localhost:9001/?key=YOURKEY
python3 lighting_handler.py    # TCP 9100 · UDP 9101 · viewer http://localhost:9102/?key=YOURKEY

Enter the endpoints in the app (use your machine's LAN IP, not localhost):

Script    192.168.1.30:9000?key=YOURKEY
Lighting  192.168.1.30:9100?key=YOURKEY&pose=both

The viewers show the connection, forwarded args, received files with previews, the live event log — and for lighting, the colored zone grid and detected poses with clothing/skin swatches. They're the fastest way to confirm your own worker's parsing against known-good output.

The key= gate is a casual-access check, not security — traffic is plaintext. Set your own value in APPKEY at the top of each handler script (it ships as changeme) and use the same value in the app's endpoint URL. For a real deployment add TLS and your own token.

Example apps you could build

The app deliberately stays "dumb" — it grabs and streams; workers interpret. That makes the worker side a playground. Some ideas, roughly ordered by ambition.

On the Script worker (sidecar files + timeline events + replies)

Subtitle / karaoke screen easy

Parse a .srt sidecar, follow position from events + heartbeats, render captions or lyrics on a TV, tablet, or projector near the viewer.

Watch-party sync easy

Relay play/pause/scrub between several headsets watching the same file — everyone's worker mirrors the leader's timeline.

4D effects rig medium

A .json cue sidecar maps timestamps to fans, scent diffusers, heat lamps, or misters — the worker fires GPIO/smart-plug cues as the timeline crosses each mark.

Haptic suit / chair driver medium

Timeline-cued rumble and impact tracks (bHaptics, buttkicker) from a haptics sidecar, tightly synced by the heartbeat position.

Session analytics easy

Log every start/pause/skip/scrub with positions: watch-through rates, drop-off points, most-rewatched moments — per file, across sessions.

Audio-description narrator medium

Accessibility: play recorded descriptions (a sidecar audio file per scene) on external speakers at the right timestamps, ducking during dialogue.

Stage / DMX show director advanced

For live venues: the sidecar is a full show file — the worker drives DMX fixtures, motors and practical props in lockstep with the video, and pause/scrub rehearses any cue instantly.

Branching director advanced

Use the reply channel: the worker inspects a choices sidecar and replies with commands — the seed of interactive, choose-your-path VR films.

On the Lighting worker (zone colors + motion + poses + clothing colors)

Ambient room lighting easy

The classic: map the 8×4 zone grid to Hue / WLED / LIFX around the room — left zones drive left lights — so the room glows with the scene. Colors arrive linear; convert to your lights' space.

Beat & motion strobes easy

Per-zone motion spikes on action — drive intensity chases and strobes from it for concert or music-video footage.

Shop-the-look fashion engine medium

The What To Wear headline act: shirt / pants / shoes / hat colors per person, live — match against a product catalog and surface "get this outfit" suggestions for whoever is on screen.

DMX / Art-Net club bridge medium

Translate zones + motion into sACN/Art-Net universes: wash fixtures follow scene color, movers react to motion, all synced to the video on the headset.

VTuber / avatar puppeteer medium

Retarget the 19 landmarks onto a rigged avatar (VMC protocol, Live2D, Unity) — the on-screen performer drives a character in real time.

Dance & fitness coach advanced

Compare the instructor's pose stream to the user's (from a webcam running the same landmark model) and score sync, rep counts, and form.

Wardrobe continuity checker medium

Film-production tool: log clothing-region colors per performer per take; flag when a shirt color drifts between setups.

Depth-mapped stage view advanced

With pose=both, triangulate each performer's distance from disparity and render a top-down blocking map — where everyone stands over time.

Chromatherapy / mood rooms easy

Slow-averaged scene palette drives calm ambient washes for spas, focus rooms, or sleep-wind-down content.

Auto camera / stream switcher advanced

Feed zone motion into OBS/vMix scene switching for a companion 2D broadcast of what the headset viewer is watching.

Every one of these starts the same way: copy binary_reader.py (or the Java/Swift codec), accept the session, and read the stream. The demo handlers are working scaffolds — fork one.