From 61c81398bb56045e4258f8ff88250a5ed94d605b Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 28 Mar 2026 22:31:54 +0000 Subject: [PATCH] feat: node-to-node MJPEG streaming CLIs and shared V4L2 format header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stream_send_cli (V4L2 capture → TCP → VIDEO_FRAME) and stream_recv_cli (TCP → threaded frame slot → GLFW display) to exercise end-to-end streaming between two nodes on the same machine or across the network. Add include/stream_stats.h (header-only rolling-window fps/Mbps tracker) and include/v4l2_fmt.h (header-only V4L2 format enumeration shared between v4l2_view_cli and stream_send_cli). Refactor v4l2_view_cli to use the shared header. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 + dev/cli/Makefile | 18 +- dev/cli/stream_recv_cli.c | 336 ++++++++++++++++++++++++++++++++++++++ dev/cli/stream_send_cli.c | 284 ++++++++++++++++++++++++++++++++ dev/cli/v4l2_view_cli.c | 181 +++----------------- include/stream_stats.h | 76 +++++++++ include/v4l2_fmt.h | 155 ++++++++++++++++++ planning.md | 2 + 8 files changed, 889 insertions(+), 165 deletions(-) create mode 100644 dev/cli/stream_recv_cli.c create mode 100644 dev/cli/stream_send_cli.c create mode 100644 include/stream_stats.h create mode 100644 include/v4l2_fmt.h diff --git a/README.md b/README.md index cb8e64f..fd8be18 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Designed to run on resource-constrained hardware (Raspberry Pi capturing raw MJP - `test_image_cli` — generate test patterns and write PPM output for visual inspection - `xorg_cli` — display a test pattern in the viewer window; exercises scale/anchor modes and text overlays - `v4l2_view_cli` — live camera viewer; auto-selects highest-FPS format, displays FPS overlay +- `stream_send_cli` — capture MJPEG from V4L2, connect to a receiver over TCP, stream VIDEO_FRAME messages with per-stream stats +- `stream_recv_cli` — listen for incoming TCP stream, display received MJPEG frames with fps/Mbps overlay ## Structure diff --git a/dev/cli/Makefile b/dev/cli/Makefile index 170d5fd..739ecf8 100644 --- a/dev/cli/Makefile +++ b/dev/cli/Makefile @@ -23,7 +23,9 @@ CLI_SRCS = \ query_cli.c \ test_image_cli.c \ xorg_cli.c \ - v4l2_view_cli.c + v4l2_view_cli.c \ + stream_send_cli.c \ + stream_recv_cli.c CLI_OBJS = $(CLI_SRCS:%.c=$(CLI_BUILD)/%.o) @@ -39,7 +41,9 @@ all: \ $(CLI_BUILD)/query_cli \ $(CLI_BUILD)/test_image_cli \ $(CLI_BUILD)/xorg_cli \ - $(CLI_BUILD)/v4l2_view_cli + $(CLI_BUILD)/v4l2_view_cli \ + $(CLI_BUILD)/stream_send_cli \ + $(CLI_BUILD)/stream_recv_cli # Module objects delegate to their sub-makes. $(COMMON_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/common @@ -88,6 +92,12 @@ $(CLI_BUILD)/xorg_cli: $(CLI_BUILD)/xorg_cli.o $(TEST_IMAGE_OBJ) $(XORG_OBJ) $(CLI_BUILD)/v4l2_view_cli: $(CLI_BUILD)/v4l2_view_cli.o $(XORG_OBJ) $(CC) $(CFLAGS) -o $@ $^ $(PKG_LDFLAGS) +$(CLI_BUILD)/stream_send_cli: $(CLI_BUILD)/stream_send_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread + +$(CLI_BUILD)/stream_recv_cli: $(CLI_BUILD)/stream_recv_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ) $(XORG_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread $(PKG_LDFLAGS) + $(CLI_BUILD): mkdir -p $@ @@ -104,6 +114,8 @@ clean: $(CLI_BUILD)/query_cli \ $(CLI_BUILD)/test_image_cli \ $(CLI_BUILD)/xorg_cli \ - $(CLI_BUILD)/v4l2_view_cli + $(CLI_BUILD)/v4l2_view_cli \ + $(CLI_BUILD)/stream_send_cli \ + $(CLI_BUILD)/stream_recv_cli -include $(CLI_OBJS:%.o=%.d) diff --git a/dev/cli/stream_recv_cli.c b/dev/cli/stream_recv_cli.c new file mode 100644 index 0000000..3290261 --- /dev/null +++ b/dev/cli/stream_recv_cli.c @@ -0,0 +1,336 @@ +#include +#include +#include +#include +#include + +#include "xorg.h" +#include "transport.h" +#include "protocol.h" +#include "stream_stats.h" +#include "error.h" + +#define DEFAULT_PORT 7700 +#define DEFAULT_WIN_W 1280 +#define DEFAULT_WIN_H 720 + +/* ------------------------------------------------------------------ */ +/* Frame slot — single-frame handoff from transport thread to GL thread */ +/* ------------------------------------------------------------------ */ + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t cond; + uint8_t *payload; /* transport-malloc'd; we own */ + const uint8_t *data; /* points into payload */ + uint32_t data_len; + uint16_t stream_id; + int ready; /* 1 = new frame available */ + int done; /* 1 = sender disconnected */ +} Frame_Slot; + +static void frame_slot_init(Frame_Slot *s) +{ + pthread_mutex_init(&s->mutex, NULL); + pthread_cond_init(&s->cond, NULL); + s->payload = NULL; + s->data = NULL; + s->data_len = 0; + s->ready = 0; + s->done = 0; +} + +/* ------------------------------------------------------------------ */ +/* Receiver state */ +/* ------------------------------------------------------------------ */ + +typedef struct { + Frame_Slot *slot; + int stream_id_filter; /* 0 = accept any */ + pthread_mutex_t conn_mutex; + struct Transport_Conn *conn; /* current sender connection */ +} Recv_State; + +/* ------------------------------------------------------------------ */ +/* Transport callbacks */ +/* ------------------------------------------------------------------ */ + +static void on_connect(struct Transport_Conn *conn, void *userdata) +{ + Recv_State *rs = userdata; + fprintf(stderr, "stream_recv_cli: sender connected\n"); + pthread_mutex_lock(&rs->conn_mutex); + rs->conn = conn; + pthread_mutex_unlock(&rs->conn_mutex); +} + +static void on_disconnect(struct Transport_Conn *conn, void *userdata) +{ + (void)conn; + Recv_State *rs = userdata; + fprintf(stderr, "stream_recv_cli: sender disconnected\n"); + + pthread_mutex_lock(&rs->conn_mutex); + rs->conn = NULL; + pthread_mutex_unlock(&rs->conn_mutex); + + Frame_Slot *slot = rs->slot; + pthread_mutex_lock(&slot->mutex); + slot->done = 1; + pthread_cond_signal(&slot->cond); + pthread_mutex_unlock(&slot->mutex); +} + +static void on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, + void *userdata) +{ + Recv_State *rs = userdata; + + if (frame->message_type == PROTO_MSG_VIDEO_FRAME) { + struct Proto_Video_Frame vf; + struct App_Error err = proto_read_video_frame( + frame->payload, frame->payload_length, &vf); + if (!APP_IS_OK(err)) { + free(frame->payload); + return; + } + if (rs->stream_id_filter && vf.stream_id != (uint16_t)rs->stream_id_filter) { + free(frame->payload); + return; + } + + Frame_Slot *slot = rs->slot; + pthread_mutex_lock(&slot->mutex); + if (slot->ready) { + free(slot->payload); /* drop stale frame — main thread is behind */ + } + slot->payload = frame->payload; /* take ownership */ + slot->data = vf.data; + slot->data_len = vf.data_len; + slot->stream_id = vf.stream_id; + slot->ready = 1; + pthread_cond_signal(&slot->cond); + pthread_mutex_unlock(&slot->mutex); + /* frame->payload is now owned by slot; do not free here */ + + } else if (frame->message_type == PROTO_MSG_CONTROL_REQUEST) { + struct Proto_Request_Header hdr; + struct App_Error err = proto_read_request_header( + frame->payload, frame->payload_length, &hdr); + if (APP_IS_OK(err) && hdr.command == PROTO_CMD_STREAM_OPEN) { + struct Proto_Stream_Open so; + err = proto_read_stream_open( + frame->payload, frame->payload_length, &so); + if (APP_IS_OK(err)) { + fprintf(stderr, + "stream_recv_cli: STREAM_OPEN stream_id=%u format=%u origin=%u\n", + so.stream_id, so.format, so.origin); + proto_write_control_response(conn, hdr.request_id, + PROTO_STATUS_OK, NULL, 0); + } + } + free(frame->payload); + + } else { + free(frame->payload); + } +} + +/* ------------------------------------------------------------------ */ +/* Usage */ +/* ------------------------------------------------------------------ */ + +static void usage(void) +{ + fprintf(stderr, + "usage: stream_recv_cli [--port PORT] [--stream-id N]\n" + " [--scale stretch|fit|fill|1:1]\n" + " [--anchor center|topleft]\n" + " [--x N] [--y N]\n" + "\n" + "Listens for an incoming TCP stream and displays VIDEO_FRAME messages.\n" + "Accepts MJPEG streams. Shows per-stream fps and Mbps as an overlay.\n" + "\n" + "defaults: port=7700 stream-id=0 (any) fit center x=0 y=0\n"); +} + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + uint16_t port = DEFAULT_PORT; + int stream_id_filter = 0; + int win_x = 0; + int win_y = 0; + Xorg_Scale scale = XORG_SCALE_FIT; + Xorg_Anchor anchor = XORG_ANCHOR_CENTER; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) { + port = (uint16_t)atoi(argv[++i]); + } else if (strcmp(argv[i], "--stream-id") == 0 && i + 1 < argc) { + stream_id_filter = atoi(argv[++i]); + } else if (strcmp(argv[i], "--x") == 0 && i + 1 < argc) { + win_x = atoi(argv[++i]); + } else if (strcmp(argv[i], "--y") == 0 && i + 1 < argc) { + win_y = atoi(argv[++i]); + } else if (strcmp(argv[i], "--scale") == 0 && i + 1 < argc) { + i++; + if (strcmp(argv[i], "stretch") == 0) { scale = XORG_SCALE_STRETCH; } + else if (strcmp(argv[i], "fit") == 0) { scale = XORG_SCALE_FIT; } + else if (strcmp(argv[i], "fill") == 0) { scale = XORG_SCALE_FILL; } + else if (strcmp(argv[i], "1:1") == 0) { scale = XORG_SCALE_1_1; } + else { fprintf(stderr, "unknown scale: %s\n", argv[i]); usage(); return 1; } + } else if (strcmp(argv[i], "--anchor") == 0 && i + 1 < argc) { + i++; + if (strcmp(argv[i], "center") == 0) { anchor = XORG_ANCHOR_CENTER; } + else if (strcmp(argv[i], "topleft") == 0) { anchor = XORG_ANCHOR_TOP_LEFT; } + else { fprintf(stderr, "unknown anchor: %s\n", argv[i]); usage(); return 1; } + } else { + usage(); return 1; + } + } + + if (!xorg_available()) { + fprintf(stderr, "stream_recv_cli: built without HAVE_GLFW — viewer not available\n"); + return 1; + } + + /* ---------------------------------------------------------------- */ + /* Frame slot and receiver state */ + /* ---------------------------------------------------------------- */ + + Frame_Slot slot; + frame_slot_init(&slot); + + Recv_State rs = {0}; + rs.slot = &slot; + rs.stream_id_filter = stream_id_filter; + pthread_mutex_init(&rs.conn_mutex, NULL); + + /* ---------------------------------------------------------------- */ + /* Transport server */ + /* ---------------------------------------------------------------- */ + + struct Transport_Server_Config cfg = { + .port = port, + .max_connections = 1, + .max_payload = TRANSPORT_DEFAULT_MAX_PAYLOAD, + .on_frame = on_frame, + .on_connect = on_connect, + .on_disconnect = on_disconnect, + .userdata = &rs, + }; + + struct Transport_Server *server = NULL; + struct App_Error err = transport_server_create(&server, &cfg); + if (!APP_IS_OK(err)) { app_error_print(&err); return 1; } + + err = transport_server_start(server); + if (!APP_IS_OK(err)) { + app_error_print(&err); + transport_server_destroy(server); + return 1; + } + + fprintf(stderr, "stream_recv_cli: listening on port %u\n", port); + + /* ---------------------------------------------------------------- */ + /* Open viewer */ + /* ---------------------------------------------------------------- */ + + Xorg_Viewer *v = xorg_viewer_open(win_x, win_y, + DEFAULT_WIN_W, DEFAULT_WIN_H, + "stream_recv_cli"); + if (!v) { + fprintf(stderr, "stream_recv_cli: failed to open viewer window\n"); + transport_server_destroy(server); + return 1; + } + xorg_viewer_set_scale(v, scale); + xorg_viewer_set_anchor(v, anchor); + xorg_viewer_set_overlay_text(v, 0, 10, 10, + "waiting for stream...", 1.0f, 1.0f, 0.8f); + + /* ---------------------------------------------------------------- */ + /* Render loop */ + /* ---------------------------------------------------------------- */ + + Stream_Stats stats; + stream_stats_init(&stats, (uint16_t)stream_id_filter); + + while (xorg_viewer_handle_events(v)) { + /* + * Wait for the next frame with a 100ms deadline so we can poll + * window events even when no frames arrive. + */ + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_nsec += 100000000LL; + if (deadline.tv_nsec >= 1000000000LL) { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000LL; + } + + pthread_mutex_lock(&slot.mutex); + while (!slot.ready && !slot.done) { + int rc = pthread_cond_timedwait(&slot.cond, &slot.mutex, &deadline); + if (rc != 0) { break; } /* timeout — poll events */ + } + + if (!slot.ready) { + int done = slot.done; + pthread_mutex_unlock(&slot.mutex); + if (done) { break; } + continue; + } + + /* Take ownership of the frame. */ + uint8_t *payload = slot.payload; + const uint8_t *data = slot.data; + uint32_t data_len = slot.data_len; + uint16_t stream_id = slot.stream_id; + slot.payload = NULL; + slot.data = NULL; + slot.ready = 0; + pthread_mutex_unlock(&slot.mutex); + + xorg_viewer_push_mjpeg(v, data, data_len); + stream_stats_record_frame(&stats, data_len); + free(payload); + + if (stream_stats_tick(&stats)) { + char info[64]; + snprintf(info, sizeof(info), "stream %u %.1f fps %.2f Mbps", + stream_id, stats.fps, stats.mbps); + xorg_viewer_set_overlay_text(v, 0, 10, 10, info, 1.0f, 1.0f, 0.8f); + } + } + + /* ---------------------------------------------------------------- */ + /* Cleanup */ + /* ---------------------------------------------------------------- */ + + /* Close the active connection if still open. */ + pthread_mutex_lock(&rs.conn_mutex); + if (rs.conn) { + transport_conn_close(rs.conn); + rs.conn = NULL; + } + pthread_mutex_unlock(&rs.conn_mutex); + + xorg_viewer_close(v); + transport_server_destroy(server); + + pthread_mutex_lock(&slot.mutex); + free(slot.payload); + pthread_mutex_unlock(&slot.mutex); + pthread_mutex_destroy(&slot.mutex); + pthread_cond_destroy(&slot.cond); + pthread_mutex_destroy(&rs.conn_mutex); + + return 0; +} diff --git a/dev/cli/stream_send_cli.c b/dev/cli/stream_send_cli.c new file mode 100644 index 0000000..19ccc48 --- /dev/null +++ b/dev/cli/stream_send_cli.c @@ -0,0 +1,284 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "v4l2_fmt.h" +#include "transport.h" +#include "protocol.h" +#include "stream_stats.h" +#include "error.h" + +#define N_BUFS 4 +#define DEFAULT_PORT 7700 +#define DEFAULT_STREAM_ID 1 + +typedef struct { void *start; size_t length; } Mmap_Buf; + +/* ------------------------------------------------------------------ */ +/* Transport callbacks */ +/* ------------------------------------------------------------------ */ + +static void on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, + void *userdata) +{ + (void)conn; (void)userdata; + /* Receiver may send responses; just discard them. */ + free(frame->payload); +} + +static void on_disconnect(struct Transport_Conn *conn, void *userdata) +{ + (void)conn; (void)userdata; + fprintf(stderr, "stream_send_cli: disconnected from receiver\n"); +} + +/* ------------------------------------------------------------------ */ +/* Usage */ +/* ------------------------------------------------------------------ */ + +static void usage(void) +{ + fprintf(stderr, + "usage: stream_send_cli [--device PATH] [--host HOST] [--port PORT]\n" + " [--stream-id N]\n" + "\n" + "Captures MJPEG from a V4L2 device and streams VIDEO_FRAME messages over TCP.\n" + "Prints frame rate and throughput to stderr every 0.5 s.\n" + "\n" + "defaults: /dev/video0 127.0.0.1 7700 stream-id=1\n"); +} + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + const char *device = "/dev/video0"; + const char *host = "127.0.0.1"; + uint16_t port = DEFAULT_PORT; + uint16_t stream_id = DEFAULT_STREAM_ID; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--device") == 0 && i + 1 < argc) { + device = argv[++i]; + } else if (strcmp(argv[i], "--host") == 0 && i + 1 < argc) { + host = argv[++i]; + } else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) { + port = (uint16_t)atoi(argv[++i]); + } else if (strcmp(argv[i], "--stream-id") == 0 && i + 1 < argc) { + stream_id = (uint16_t)atoi(argv[++i]); + } else { + usage(); return 1; + } + } + + /* ---------------------------------------------------------------- */ + /* Open V4L2 device */ + /* ---------------------------------------------------------------- */ + + int fd = open(device, O_RDWR | O_NONBLOCK); + if (fd < 0) { perror(device); return 1; } + + struct v4l2_capability cap = {0}; + if (v4l2_xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { + perror("VIDIOC_QUERYCAP"); close(fd); return 1; + } + if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { + fprintf(stderr, "%s: not a capture device\n", device); close(fd); return 1; + } + if (!(cap.capabilities & V4L2_CAP_STREAMING)) { + fprintf(stderr, "%s: does not support streaming\n", device); close(fd); return 1; + } + + /* ---------------------------------------------------------------- */ + /* Format selection — MJPEG, best FPS then largest size */ + /* ---------------------------------------------------------------- */ + + V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS]; + int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, V4L2_PIX_FMT_MJPEG); + if (n == 0) { + fprintf(stderr, "%s: no MJPEG formats found\n", device); + close(fd); return 1; + } + const V4l2_Fmt_Option *best = v4l2_select_best(opts, n); + + struct v4l2_format fmt = {0}; + fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + fmt.fmt.pix.pixelformat = best->pixfmt; + fmt.fmt.pix.width = (uint32_t)best->w; + fmt.fmt.pix.height = (uint32_t)best->h; + fmt.fmt.pix.field = V4L2_FIELD_ANY; + if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { + perror("VIDIOC_S_FMT"); close(fd); return 1; + } + + int width = (int)fmt.fmt.pix.width; + int height = (int)fmt.fmt.pix.height; + int fps_n = best->fps_n; + int fps_d = best->fps_d; + + { + struct v4l2_streamparm parm = {0}; + parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + parm.parm.capture.timeperframe.numerator = (uint32_t)fps_d; + parm.parm.capture.timeperframe.denominator = (uint32_t)fps_n; + v4l2_xioctl(fd, VIDIOC_S_PARM, &parm); + if (v4l2_xioctl(fd, VIDIOC_G_PARM, &parm) == 0 && + parm.parm.capture.timeperframe.denominator > 0) { + fps_n = (int)parm.parm.capture.timeperframe.denominator; + fps_d = (int)parm.parm.capture.timeperframe.numerator; + } + } + + fprintf(stderr, "device: %s (%s)\n", device, (char *)cap.card); + fprintf(stderr, "format: MJPEG %dx%d target=%.1f fps\n", + width, height, + fps_d > 0 ? (double)fps_n / fps_d : 0.0); + + /* ---------------------------------------------------------------- */ + /* Mmap buffers + stream on */ + /* ---------------------------------------------------------------- */ + + struct v4l2_requestbuffers req = {0}; + req.count = N_BUFS; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + if (v4l2_xioctl(fd, VIDIOC_REQBUFS, &req) < 0) { + perror("VIDIOC_REQBUFS"); close(fd); return 1; + } + + Mmap_Buf bufs[N_BUFS] = {0}; + for (unsigned i = 0; i < req.count; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + if (v4l2_xioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) { + perror("VIDIOC_QUERYBUF"); close(fd); return 1; + } + bufs[i].length = buf.length; + bufs[i].start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, buf.m.offset); + if (bufs[i].start == MAP_FAILED) { + perror("mmap"); close(fd); return 1; + } + } + + for (unsigned i = 0; i < req.count; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) { + perror("VIDIOC_QBUF"); close(fd); return 1; + } + } + + enum v4l2_buf_type stream_type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (v4l2_xioctl(fd, VIDIOC_STREAMON, &stream_type) < 0) { + perror("VIDIOC_STREAMON"); close(fd); return 1; + } + + /* ---------------------------------------------------------------- */ + /* Connect to receiver */ + /* ---------------------------------------------------------------- */ + + struct Transport_Conn *conn = NULL; + struct App_Error err = transport_connect(&conn, host, port, + TRANSPORT_DEFAULT_MAX_PAYLOAD, + on_frame, on_disconnect, NULL); + if (!APP_IS_OK(err)) { + app_error_print(&err); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + close(fd); return 1; + } + + fprintf(stderr, "connected to %s:%u stream_id=%u\n", host, port, stream_id); + + err = proto_write_stream_open(conn, 1 /* request_id */, stream_id, + PROTO_FORMAT_MJPEG, + 0 /* pixel_format: compressed */, + PROTO_ORIGIN_DEVICE_NATIVE); + if (!APP_IS_OK(err)) { + app_error_print(&err); + transport_conn_close(conn); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + close(fd); return 1; + } + + /* ---------------------------------------------------------------- */ + /* Capture + send loop */ + /* ---------------------------------------------------------------- */ + + Stream_Stats stats; + stream_stats_init(&stats, stream_id); + + while (1) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv = {1, 0}; + int r = select(fd + 1, &fds, NULL, NULL, &tv); + + if (r < 0) { + if (errno == EINTR) { continue; } + perror("select"); break; + } + if (r == 0) { + fprintf(stderr, "stream_send_cli: select timeout — no frames\n"); + continue; + } + + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + if (v4l2_xioctl(fd, VIDIOC_DQBUF, &buf) < 0) { + if (errno == EAGAIN) { continue; } + perror("VIDIOC_DQBUF"); break; + } + + const uint8_t *data = (const uint8_t *)bufs[buf.index].start; + uint32_t nbytes = buf.bytesused; + + err = proto_write_video_frame(conn, stream_id, data, nbytes); + + if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) { + perror("VIDIOC_QBUF"); + if (!APP_IS_OK(err)) { app_error_print(&err); } + break; + } + + if (!APP_IS_OK(err)) { + app_error_print(&err); + break; + } + + stream_stats_record_frame(&stats, nbytes); + if (stream_stats_tick(&stats)) { + fprintf(stderr, "stream %u %.1f fps %.2f Mbps\n", + stream_id, stats.fps, stats.mbps); + } + } + + /* ---------------------------------------------------------------- */ + /* Cleanup */ + /* ---------------------------------------------------------------- */ + + transport_conn_close(conn); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + for (unsigned i = 0; i < req.count; i++) { + if (bufs[i].start && bufs[i].start != MAP_FAILED) { + munmap(bufs[i].start, bufs[i].length); + } + } + close(fd); + return 0; +} diff --git a/dev/cli/v4l2_view_cli.c b/dev/cli/v4l2_view_cli.c index ac97530..ed02b7d 100644 --- a/dev/cli/v4l2_view_cli.c +++ b/dev/cli/v4l2_view_cli.c @@ -3,157 +3,14 @@ #include #include #include -#include #include -#include #include #include -#include +#include "v4l2_fmt.h" #include "xorg.h" #define N_BUFS 4 -#define MAX_OPTS 1024 - -/* ------------------------------------------------------------------ */ -/* Format option — one (pixfmt, size, fps) combination */ -/* ------------------------------------------------------------------ */ - -typedef struct { - uint32_t pixfmt; - int w, h; - int fps_n; /* fps = fps_n / fps_d */ - int fps_d; -} Fmt_Option; - -/* fps_a > fps_b ? */ -static int fps_gt(const Fmt_Option *a, const Fmt_Option *b) -{ - return (long long)a->fps_n * b->fps_d > (long long)b->fps_n * a->fps_d; -} - -static int fps_eq(const Fmt_Option *a, const Fmt_Option *b) -{ - return (long long)a->fps_n * b->fps_d == (long long)b->fps_n * a->fps_d; -} - -/* ------------------------------------------------------------------ */ -/* Format enumeration */ -/* ------------------------------------------------------------------ */ - -typedef struct { - Fmt_Option *opts; - int n; - int max; -} Opt_List; - -static void opt_push(Opt_List *l, uint32_t pixfmt, int w, int h, int fps_n, int fps_d) -{ - if (l->n >= l->max) { return; } - l->opts[l->n++] = (Fmt_Option){ pixfmt, w, h, fps_n, fps_d }; -} - -static int xioctl(int fd, unsigned long req, void *arg) -{ - int r; - do { r = ioctl(fd, req, arg); } while (r == -1 && errno == EINTR); - return r; -} - -static void collect_intervals(int fd, uint32_t pixfmt, int w, int h, Opt_List *l) -{ - struct v4l2_frmivalenum fie = {0}; - fie.pixel_format = pixfmt; - fie.width = (uint32_t)w; - fie.height = (uint32_t)h; - - for (fie.index = 0; xioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &fie) == 0; fie.index++) { - if (fie.type == V4L2_FRMIVAL_TYPE_DISCRETE) { - opt_push(l, pixfmt, w, h, - (int)fie.discrete.denominator, - (int)fie.discrete.numerator); - } else { - /* Stepwise/continuous: record the fastest (minimum) interval. */ - opt_push(l, pixfmt, w, h, - (int)fie.stepwise.min.denominator, - (int)fie.stepwise.min.numerator); - break; - } - } -} - -static void collect_sizes(int fd, uint32_t pixfmt, Opt_List *l) -{ - struct v4l2_frmsizeenum fse = {0}; - fse.pixel_format = pixfmt; - - for (fse.index = 0; xioctl(fd, VIDIOC_ENUM_FRAMESIZES, &fse) == 0; fse.index++) { - if (fse.type == V4L2_FRMSIZE_TYPE_DISCRETE) { - collect_intervals(fd, pixfmt, - (int)fse.discrete.width, - (int)fse.discrete.height, l); - } else { - /* Stepwise/continuous: only probe the maximum size. */ - collect_intervals(fd, pixfmt, - (int)fse.stepwise.max_width, - (int)fse.stepwise.max_height, l); - break; - } - } -} - -/* - * Enumerate all (pixfmt, size, fps) combinations the device supports. - * Filtered to formats we can handle (MJPEG, YUYV). - * If fmt_filter is non-zero, only that pixel format is considered. - */ -static int enumerate_formats(int fd, Fmt_Option *buf, int buf_max, - uint32_t fmt_filter) -{ - Opt_List l = { buf, 0, buf_max }; - - struct v4l2_fmtdesc fd_desc = {0}; - fd_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - - for (fd_desc.index = 0; - xioctl(fd, VIDIOC_ENUM_FMT, &fd_desc) == 0; - fd_desc.index++) { - uint32_t pf = fd_desc.pixelformat; - if (pf != V4L2_PIX_FMT_MJPEG && pf != V4L2_PIX_FMT_YUYV) { continue; } - if (fmt_filter && pf != fmt_filter) { continue; } - collect_sizes(fd, pf, &l); - } - return l.n; -} - -/* - * Select the best option from the list: - * 1. Highest FPS - * 2. Largest area (w×h) among equal-FPS entries - * 3. MJPEG preferred over YUYV for equal FPS and area - */ -static const Fmt_Option *select_best(const Fmt_Option *opts, int n) -{ - if (n == 0) { return NULL; } - const Fmt_Option *best = &opts[0]; - for (int i = 1; i < n; i++) { - const Fmt_Option *o = &opts[i]; - if (fps_gt(o, best)) { - best = o; - } else if (fps_eq(o, best)) { - int o_area = o->w * o->h; - int b_area = best->w * best->h; - if (o_area > b_area) { - best = o; - } else if (o_area == b_area && - o->pixfmt == V4L2_PIX_FMT_MJPEG && - best->pixfmt != V4L2_PIX_FMT_MJPEG) { - best = o; - } - } - } - return best; -} /* ------------------------------------------------------------------ */ /* YUYV → planar YUV420 conversion */ @@ -269,7 +126,7 @@ int main(int argc, char **argv) if (fd < 0) { perror(device); return 1; } struct v4l2_capability cap = {0}; - if (xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { + if (v4l2_xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { perror("VIDIOC_QUERYCAP"); close(fd); return 1; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { @@ -301,14 +158,14 @@ int main(int argc, char **argv) use_mjpeg = 0; if (fmt_filter != V4L2_PIX_FMT_YUYV) { fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG; - if (xioctl(fd, VIDIOC_S_FMT, &fmt) == 0 && + if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) == 0 && fmt.fmt.pix.pixelformat == V4L2_PIX_FMT_MJPEG) { use_mjpeg = 1; } } if (!use_mjpeg) { fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; - if (xioctl(fd, VIDIOC_S_FMT, &fmt) < 0 || + if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0 || fmt.fmt.pix.pixelformat != V4L2_PIX_FMT_YUYV) { fprintf(stderr, "%s: could not set %dx%d in MJPEG or YUYV\n", device, req_width, req_height); @@ -321,16 +178,16 @@ int main(int argc, char **argv) sel_fps_n = 0; sel_fps_d = 1; /* unknown until G_PARM below */ } else { /* Enumerate all supported modes and pick the best. */ - Fmt_Option *opts = malloc(MAX_OPTS * sizeof(*opts)); + V4l2_Fmt_Option *opts = malloc(V4L2_FMT_MAX_OPTS * sizeof(*opts)); if (!opts) { fprintf(stderr, "out of memory\n"); close(fd); return 1; } - int n = enumerate_formats(fd, opts, MAX_OPTS, fmt_filter); + int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, fmt_filter); if (n == 0) { fprintf(stderr, "%s: no usable formats found (MJPEG/YUYV)\n", device); free(opts); close(fd); return 1; } - const Fmt_Option *best = select_best(opts, n); + const V4l2_Fmt_Option *best = v4l2_select_best(opts, n); /* Apply the selected format. */ struct v4l2_format fmt = {0}; @@ -339,7 +196,7 @@ int main(int argc, char **argv) fmt.fmt.pix.width = (uint32_t)best->w; fmt.fmt.pix.height = (uint32_t)best->h; fmt.fmt.pix.field = V4L2_FIELD_ANY; - if (xioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { + if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("VIDIOC_S_FMT"); free(opts); close(fd); return 1; } @@ -358,9 +215,9 @@ int main(int argc, char **argv) parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; parm.parm.capture.timeperframe.numerator = (uint32_t)sel_fps_d; parm.parm.capture.timeperframe.denominator = (uint32_t)sel_fps_n; - xioctl(fd, VIDIOC_S_PARM, &parm); + v4l2_xioctl(fd, VIDIOC_S_PARM, &parm); /* Read back what the driver actually set. */ - if (xioctl(fd, VIDIOC_G_PARM, &parm) == 0 && + if (v4l2_xioctl(fd, VIDIOC_G_PARM, &parm) == 0 && parm.parm.capture.timeperframe.denominator > 0) { sel_fps_n = (int)parm.parm.capture.timeperframe.denominator; sel_fps_d = (int)parm.parm.capture.timeperframe.numerator; @@ -380,7 +237,7 @@ int main(int argc, char **argv) req.count = N_BUFS; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; - if (xioctl(fd, VIDIOC_REQBUFS, &req) < 0) { + if (v4l2_xioctl(fd, VIDIOC_REQBUFS, &req) < 0) { perror("VIDIOC_REQBUFS"); close(fd); return 1; } @@ -390,7 +247,7 @@ int main(int argc, char **argv) buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; - if (xioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) { + if (v4l2_xioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) { perror("VIDIOC_QUERYBUF"); close(fd); return 1; } bufs[i].length = buf.length; @@ -406,13 +263,13 @@ int main(int argc, char **argv) buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; - if (xioctl(fd, VIDIOC_QBUF, &buf) < 0) { + if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) { perror("VIDIOC_QBUF"); close(fd); return 1; } } enum v4l2_buf_type stream_type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - if (xioctl(fd, VIDIOC_STREAMON, &stream_type) < 0) { + if (v4l2_xioctl(fd, VIDIOC_STREAMON, &stream_type) < 0) { perror("VIDIOC_STREAMON"); close(fd); return 1; } @@ -423,7 +280,7 @@ int main(int argc, char **argv) Xorg_Viewer *v = xorg_viewer_open(win_x, win_y, width, height, "v4l2_view_cli"); if (!v) { fprintf(stderr, "v4l2_view_cli: failed to open viewer window\n"); - xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); close(fd); return 1; } xorg_viewer_set_scale(v, scale); @@ -439,7 +296,7 @@ int main(int argc, char **argv) if (!yuv420_buf) { fprintf(stderr, "v4l2_view_cli: out of memory\n"); xorg_viewer_close(v); - xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); close(fd); return 1; } } @@ -480,7 +337,7 @@ int main(int argc, char **argv) struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; - if (xioctl(fd, VIDIOC_DQBUF, &buf) < 0) { + if (v4l2_xioctl(fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) { continue; } perror("VIDIOC_DQBUF"); break; } @@ -497,7 +354,7 @@ int main(int argc, char **argv) xorg_viewer_push_yuv420(v, y_p, cb_p, cr_p, width, height); } - if (xioctl(fd, VIDIOC_QBUF, &buf) < 0) { + if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) { perror("VIDIOC_QBUF"); break; } @@ -525,7 +382,7 @@ int main(int argc, char **argv) /* ---------------------------------------------------------------- */ xorg_viewer_close(v); - xioctl(fd, VIDIOC_STREAMOFF, &stream_type); + v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type); for (unsigned i = 0; i < req.count; i++) { if (bufs[i].start && bufs[i].start != MAP_FAILED) { munmap(bufs[i].start, bufs[i].length); diff --git a/include/stream_stats.h b/include/stream_stats.h new file mode 100644 index 0000000..ee09ce2 --- /dev/null +++ b/include/stream_stats.h @@ -0,0 +1,76 @@ +#pragma once + +/* + * Lightweight per-stream statistics tracker. + * Header-only; include wherever stream send/receive happens. + * + * Usage: + * Stream_Stats s; + * stream_stats_init(&s, stream_id); + * + * // on each frame: + * stream_stats_record_frame(&s, byte_count); + * + * // periodically (e.g. after every frame): + * if (stream_stats_tick(&s)) { + * printf("%.1f fps %.2f Mbps\n", s.fps, s.mbps); + * } + */ + +#include +#include +#include + +#define STREAM_STATS_INTERVAL 0.5 /* recompute rates every 0.5 s */ + +typedef struct { + uint16_t stream_id; + + /* Lifetime counters — never reset. */ + uint64_t total_frames; + uint64_t total_bytes; + + /* Rolling window — reset each time rates are computed. */ + uint64_t window_frames; + uint64_t window_bytes; + struct timespec window_start; + + /* Last computed rates. */ + float fps; + float mbps; +} Stream_Stats; + +static inline void stream_stats_init(Stream_Stats *s, uint16_t stream_id) +{ + memset(s, 0, sizeof(*s)); + s->stream_id = stream_id; + clock_gettime(CLOCK_MONOTONIC, &s->window_start); +} + +/* Call once per received/sent frame. */ +static inline void stream_stats_record_frame(Stream_Stats *s, uint32_t nbytes) +{ + s->total_frames++; + s->total_bytes += nbytes; + s->window_frames++; + s->window_bytes += nbytes; +} + +/* + * Recompute fps and mbps if enough time has elapsed. + * Returns 1 when rates were updated, 0 otherwise. + */ +static inline int stream_stats_tick(Stream_Stats *s) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + double elapsed = (double)(now.tv_sec - s->window_start.tv_sec) + + (double)(now.tv_nsec - s->window_start.tv_nsec) * 1e-9; + if (elapsed < STREAM_STATS_INTERVAL) { return 0; } + s->fps = (float)((double)s->window_frames / elapsed); + s->mbps = (float)((double)s->window_bytes * 8.0 / elapsed / 1e6); + s->window_frames = 0; + s->window_bytes = 0; + s->window_start = now; + return 1; +} diff --git a/include/v4l2_fmt.h b/include/v4l2_fmt.h new file mode 100644 index 0000000..ae527d9 --- /dev/null +++ b/include/v4l2_fmt.h @@ -0,0 +1,155 @@ +#pragma once + +/* + * Header-only V4L2 format enumeration. + * Enumerates (pixfmt, size, fps) combinations for MJPEG/YUYV capture devices. + * + * Usage: + * V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS]; + * int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, 0); + * const V4l2_Fmt_Option *best = v4l2_select_best(opts, n); + */ + +#include +#include +#include +#include + +#define V4L2_FMT_MAX_OPTS 1024 + +typedef struct { + uint32_t pixfmt; + int w, h; + int fps_n; /* fps = fps_n / fps_d */ + int fps_d; +} V4l2_Fmt_Option; + +static inline int v4l2_xioctl(int fd, unsigned long req, void *arg) +{ + int r; + do { r = ioctl(fd, req, arg); } while (r == -1 && errno == EINTR); + return r; +} + +static inline int v4l2_fmt_fps_gt(const V4l2_Fmt_Option *a, const V4l2_Fmt_Option *b) +{ + return (long long)a->fps_n * b->fps_d > (long long)b->fps_n * a->fps_d; +} + +static inline int v4l2_fmt_fps_eq(const V4l2_Fmt_Option *a, const V4l2_Fmt_Option *b) +{ + return (long long)a->fps_n * b->fps_d == (long long)b->fps_n * a->fps_d; +} + +typedef struct { + V4l2_Fmt_Option *opts; + int n; + int max; +} V4l2_Opt_List; + +static inline void v4l2_opt_push(V4l2_Opt_List *l, uint32_t pixfmt, + int w, int h, int fps_n, int fps_d) +{ + if (l->n >= l->max) { return; } + l->opts[l->n++] = (V4l2_Fmt_Option){ pixfmt, w, h, fps_n, fps_d }; +} + +static inline void v4l2_collect_intervals(int fd, uint32_t pixfmt, int w, int h, + V4l2_Opt_List *l) +{ + struct v4l2_frmivalenum fie = {0}; + fie.pixel_format = pixfmt; + fie.width = (uint32_t)w; + fie.height = (uint32_t)h; + + for (fie.index = 0; + v4l2_xioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &fie) == 0; + fie.index++) { + if (fie.type == V4L2_FRMIVAL_TYPE_DISCRETE) { + v4l2_opt_push(l, pixfmt, w, h, + (int)fie.discrete.denominator, + (int)fie.discrete.numerator); + } else { + /* Stepwise/continuous: record the fastest (minimum) interval. */ + v4l2_opt_push(l, pixfmt, w, h, + (int)fie.stepwise.min.denominator, + (int)fie.stepwise.min.numerator); + break; + } + } +} + +static inline void v4l2_collect_sizes(int fd, uint32_t pixfmt, V4l2_Opt_List *l) +{ + struct v4l2_frmsizeenum fse = {0}; + fse.pixel_format = pixfmt; + + for (fse.index = 0; + v4l2_xioctl(fd, VIDIOC_ENUM_FRAMESIZES, &fse) == 0; + fse.index++) { + if (fse.type == V4L2_FRMSIZE_TYPE_DISCRETE) { + v4l2_collect_intervals(fd, pixfmt, + (int)fse.discrete.width, + (int)fse.discrete.height, l); + } else { + /* Stepwise/continuous: only probe the maximum size. */ + v4l2_collect_intervals(fd, pixfmt, + (int)fse.stepwise.max_width, + (int)fse.stepwise.max_height, l); + break; + } + } +} + +/* + * Enumerate all (pixfmt, size, fps) combos the device supports. + * Filtered to MJPEG and YUYV. fmt_filter=0 accepts both. + * Returns the count written to buf. + */ +static inline int v4l2_enumerate_formats(int fd, V4l2_Fmt_Option *buf, int buf_max, + uint32_t fmt_filter) +{ + V4l2_Opt_List l = { buf, 0, buf_max }; + + struct v4l2_fmtdesc fd_desc = {0}; + fd_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + + for (fd_desc.index = 0; + v4l2_xioctl(fd, VIDIOC_ENUM_FMT, &fd_desc) == 0; + fd_desc.index++) { + uint32_t pf = fd_desc.pixelformat; + if (pf != V4L2_PIX_FMT_MJPEG && pf != V4L2_PIX_FMT_YUYV) { continue; } + if (fmt_filter && pf != fmt_filter) { continue; } + v4l2_collect_sizes(fd, pf, &l); + } + return l.n; +} + +/* + * Select the best option from the list: + * 1. Highest FPS + * 2. Largest area (w×h) among equal-FPS entries + * 3. MJPEG preferred over YUYV on equal FPS and area + */ +static inline const V4l2_Fmt_Option *v4l2_select_best(const V4l2_Fmt_Option *opts, int n) +{ + if (n == 0) { return NULL; } + const V4l2_Fmt_Option *best = &opts[0]; + for (int i = 1; i < n; i++) { + const V4l2_Fmt_Option *o = &opts[i]; + if (v4l2_fmt_fps_gt(o, best)) { + best = o; + } else if (v4l2_fmt_fps_eq(o, best)) { + int o_area = o->w * o->h; + int b_area = best->w * best->h; + if (o_area > b_area) { + best = o; + } else if (o_area == b_area && + o->pixfmt == V4L2_PIX_FMT_MJPEG && + best->pixfmt != V4L2_PIX_FMT_MJPEG) { + best = o; + } + } + } + return best; +} diff --git a/planning.md b/planning.md index 8b1e7b8..33e09c7 100644 --- a/planning.md +++ b/planning.md @@ -82,6 +82,8 @@ Each module gets a corresponding CLI driver that exercises its API and serves as | `test_image_cli` | `test_image` | Generate test patterns, write PPM for visual inspection | | `xorg_cli` | `xorg` | Display test pattern in viewer window; exercises scale/anchor modes and text overlays | | `v4l2_view_cli` | V4L2 + `xorg` | Live camera viewer — auto-selects highest-FPS format, FPS/format overlay; bypasses node system | +| `stream_send_cli` | V4L2 + `transport` + `protocol` | Capture MJPEG from V4L2, connect to receiver, send VIDEO_FRAME messages; prints fps/Mbps stats | +| `stream_recv_cli` | `transport` + `protocol` + `xorg` | Listen for incoming VIDEO_FRAME stream, display in viewer; fps/Mbps overlay; threaded transport→GL handoff | ### Web UI (`dev/web/`)