From a1b52145d0a61f377549f3d0efc280e3d16097af Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 28 Mar 2026 20:54:07 +0000 Subject: [PATCH] feat: add test_image module, xorg viewer sink, and feature flag build system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature flags (FEATURES= make variable): glfw — GLFW + OpenGL viewer (libglfw3, libglew) vulkan — future Vulkan renderer turbojpeg — MJPEG decode/encode (libturbojpeg) xorg — XRandR geometry + screen grab (libx11, libxrandr) vaapi — VA-API hardware codec (libva) Each flag injects -DHAVE_ and the relevant pkg-config flags. Headless build: make (no FEATURES set). test_image module (src/modules/test_image/): Generates test frames in YUV420, YUV422, and BGRA. Patterns: SMPTE 75% colour bars, greyscale ramp, white/black grid. BT.601 limited-range RGB→YCbCr in write_pixel(). test_image_cli (dev/cli/): Generates a frame and writes it as a PPM file for visual verification. Usage: test_image_cli [--pattern bars|ramp|grid] [--width N] [--height N] [--format yuv420|yuv422|bgra] --out FILE.ppm xorg module (src/modules/xorg/): xorg.c — full GLFW+OpenGL implementation (compiled with FEATURES=glfw) xorg_stub.c — no-op stub (compiled otherwise; xorg_available() returns false) Renderer: full-screen quad via gl_VertexID, three GL_R8 textures for YUV, BT.601 matrix in fragment shader, GL_BGRA texture for packed frames. MJPEG path: tjDecompressToYUVPlanes → planar YUV → upload (requires turbojpeg). push_yuv420/push_bgra/push_mjpeg all usable independently. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 2 + common.mk | 48 ++++ dev/cli/Makefile | 48 ++-- dev/cli/test_image_cli.c | 147 +++++++++++ include/test_image.h | 35 +++ include/xorg.h | 42 ++++ src/modules/test_image/Makefile | 19 ++ src/modules/test_image/test_image.c | 179 +++++++++++++ src/modules/xorg/Makefile | 28 +++ src/modules/xorg/xorg.c | 377 ++++++++++++++++++++++++++++ src/modules/xorg/xorg_stub.c | 36 +++ src/node/Makefile | 7 +- 12 files changed, 946 insertions(+), 22 deletions(-) create mode 100644 dev/cli/test_image_cli.c create mode 100644 include/test_image.h create mode 100644 include/xorg.h create mode 100644 src/modules/test_image/Makefile create mode 100644 src/modules/test_image/test_image.c create mode 100644 src/modules/xorg/Makefile create mode 100644 src/modules/xorg/xorg.c create mode 100644 src/modules/xorg/xorg_stub.c diff --git a/Makefile b/Makefile index 215b088..7d35dea 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,8 @@ modules: $(MAKE) -C src/modules/discovery $(MAKE) -C src/modules/config $(MAKE) -C src/modules/protocol + $(MAKE) -C src/modules/test_image + $(MAKE) -C src/modules/xorg cli: modules $(MAKE) -C dev/cli diff --git a/common.mk b/common.mk index 0ecbc03..20da3cd 100644 --- a/common.mk +++ b/common.mk @@ -10,3 +10,51 @@ BUILD = $(ROOT)/build # -MMD emit a .d file listing header prerequisites alongside each .o # -MP add phony targets for headers so removed headers don't break make DEPFLAGS = -MMD -MP + +# ----------------------------------------------------------------------- +# Feature flags +# Set FEATURES to a space-separated list of optional deps to enable. +# Example: make FEATURES="glfw turbojpeg" +# Headless (no optional deps): make FEATURES= +# +# Available features: +# glfw — GLFW window management + OpenGL renderer (libglfw3, libglew) +# vulkan — GLFW window management + Vulkan renderer (vulkan-loader, libglfw3) +# turbojpeg — MJPEG encode/decode (libturbojpeg) +# xorg — XRandR geometry queries + XShmGetImage screen grab (libx11, libxrandr) +# vaapi — VA-API hardware encode/decode (libva) +# ----------------------------------------------------------------------- +FEATURES ?= +export FEATURES + +# Inject HAVE_ defines into CFLAGS +CFLAGS += $(if $(filter glfw, $(FEATURES)),-DHAVE_GLFW) +CFLAGS += $(if $(filter vulkan, $(FEATURES)),-DHAVE_VULKAN) +CFLAGS += $(if $(filter turbojpeg, $(FEATURES)),-DHAVE_TURBOJPEG) +CFLAGS += $(if $(filter xorg, $(FEATURES)),-DHAVE_XORG) +CFLAGS += $(if $(filter vaapi, $(FEATURES)),-DHAVE_VAAPI) + +# Per-feature pkg-config flags — accumulated into PKG_CFLAGS / PKG_LDFLAGS. +# Modules that need them add $(PKG_CFLAGS) to their compile rules. +# The node and cli link rules append $(PKG_LDFLAGS). +PKG_CFLAGS := +PKG_LDFLAGS := + +ifneq (,$(filter glfw,$(FEATURES))) + PKG_CFLAGS += $(shell pkg-config --cflags glfw3 glew 2>/dev/null) + PKG_LDFLAGS += $(shell pkg-config --libs glfw3 glew 2>/dev/null) -lGL +endif +ifneq (,$(filter turbojpeg,$(FEATURES))) + PKG_CFLAGS += $(shell pkg-config --cflags libturbojpeg 2>/dev/null) + PKG_LDFLAGS += $(shell pkg-config --libs libturbojpeg 2>/dev/null) +endif +ifneq (,$(filter xorg,$(FEATURES))) + PKG_CFLAGS += $(shell pkg-config --cflags x11 xrandr 2>/dev/null) + PKG_LDFLAGS += $(shell pkg-config --libs x11 xrandr 2>/dev/null) +endif +ifneq (,$(filter vaapi,$(FEATURES))) + PKG_CFLAGS += $(shell pkg-config --cflags libva 2>/dev/null) + PKG_LDFLAGS += $(shell pkg-config --libs libva 2>/dev/null) +endif + +CFLAGS += $(PKG_CFLAGS) diff --git a/dev/cli/Makefile b/dev/cli/Makefile index c463788..6e0c9b4 100644 --- a/dev/cli/Makefile +++ b/dev/cli/Makefile @@ -1,15 +1,16 @@ ROOT := $(abspath ../..) include $(ROOT)/common.mk -CLI_BUILD = $(BUILD)/cli -COMMON_OBJ = $(BUILD)/common/error.o -MEDIA_CTRL_OBJ = $(BUILD)/media_ctrl/media_ctrl.o -V4L2_CTRL_OBJ = $(BUILD)/v4l2_ctrl/v4l2_ctrl.o -SERIAL_OBJ = $(BUILD)/serial/serial.o -TRANSPORT_OBJ = $(BUILD)/transport/transport.o -DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o -CONFIG_OBJ = $(BUILD)/config/config.o -PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o +CLI_BUILD = $(BUILD)/cli +COMMON_OBJ = $(BUILD)/common/error.o +MEDIA_CTRL_OBJ = $(BUILD)/media_ctrl/media_ctrl.o +V4L2_CTRL_OBJ = $(BUILD)/v4l2_ctrl/v4l2_ctrl.o +SERIAL_OBJ = $(BUILD)/serial/serial.o +TRANSPORT_OBJ = $(BUILD)/transport/transport.o +DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o +CONFIG_OBJ = $(BUILD)/config/config.o +PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o +TEST_IMAGE_OBJ = $(BUILD)/test_image/test_image.o CLI_SRCS = \ media_ctrl_cli.c \ @@ -18,7 +19,8 @@ CLI_SRCS = \ discovery_cli.c \ config_cli.c \ protocol_cli.c \ - query_cli.c + query_cli.c \ + test_image_cli.c CLI_OBJS = $(CLI_SRCS:%.c=$(CLI_BUILD)/%.o) @@ -31,17 +33,19 @@ all: \ $(CLI_BUILD)/discovery_cli \ $(CLI_BUILD)/config_cli \ $(CLI_BUILD)/protocol_cli \ - $(CLI_BUILD)/query_cli + $(CLI_BUILD)/query_cli \ + $(CLI_BUILD)/test_image_cli # Module objects delegate to their sub-makes. -$(COMMON_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/common -$(MEDIA_CTRL_OBJ):; $(MAKE) -C $(ROOT)/src/modules/media_ctrl -$(V4L2_CTRL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl -$(SERIAL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/serial -$(TRANSPORT_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/transport -$(DISCOVERY_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/discovery -$(CONFIG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/config -$(PROTOCOL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/protocol +$(COMMON_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/common +$(MEDIA_CTRL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/media_ctrl +$(V4L2_CTRL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl +$(SERIAL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/serial +$(TRANSPORT_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/transport +$(DISCOVERY_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/discovery +$(CONFIG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/config +$(PROTOCOL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/protocol +$(TEST_IMAGE_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/test_image # Compile each CLI source to its own .o (generates .d alongside). $(CLI_BUILD)/%.o: %.c | $(CLI_BUILD) @@ -69,6 +73,9 @@ $(CLI_BUILD)/protocol_cli: $(CLI_BUILD)/protocol_cli.o $(COMMON_OBJ) $(SERIAL_OB $(CLI_BUILD)/query_cli: $(CLI_BUILD)/query_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(PROTOCOL_OBJ) $(CC) $(CFLAGS) -o $@ $^ -lpthread +$(CLI_BUILD)/test_image_cli: $(CLI_BUILD)/test_image_cli.o $(TEST_IMAGE_OBJ) + $(CC) $(CFLAGS) -o $@ $^ + $(CLI_BUILD): mkdir -p $@ @@ -82,6 +89,7 @@ clean: $(CLI_BUILD)/discovery_cli \ $(CLI_BUILD)/config_cli \ $(CLI_BUILD)/protocol_cli \ - $(CLI_BUILD)/query_cli + $(CLI_BUILD)/query_cli \ + $(CLI_BUILD)/test_image_cli -include $(CLI_OBJS:%.o=%.d) diff --git a/dev/cli/test_image_cli.c b/dev/cli/test_image_cli.c new file mode 100644 index 0000000..1ba38b7 --- /dev/null +++ b/dev/cli/test_image_cli.c @@ -0,0 +1,147 @@ +#include +#include +#include +#include "test_image.h" + +/* ------------------------------------------------------------------ */ +/* BT.601 limited-range YCbCr → RGB (for PPM output only) */ +/* ------------------------------------------------------------------ */ + +static void ycbcr_to_rgb(uint8_t y, uint8_t cb, uint8_t cr, + uint8_t *r, uint8_t *g, uint8_t *b) +{ + int yy = (int)y - 16; + int cb_ = (int)cb - 128; + int cr_ = (int)cr - 128; + int rv = (298 * yy + 409 * cr_ + 128) >> 8; + int gv = (298 * yy - 100 * cb_ - 208 * cr_ + 128) >> 8; + int bv = (298 * yy + 516 * cb_ + 128) >> 8; + *r = (uint8_t)(rv < 0 ? 0 : rv > 255 ? 255 : rv); + *g = (uint8_t)(gv < 0 ? 0 : gv > 255 ? 255 : gv); + *b = (uint8_t)(bv < 0 ? 0 : bv > 255 ? 255 : bv); +} + +/* ------------------------------------------------------------------ */ +/* PPM output */ +/* ------------------------------------------------------------------ */ + +static int write_ppm(const char *path, Test_Frame *f) +{ + FILE *fp = fopen(path, "wb"); + if (!fp) { perror(path); return -1; } + + fprintf(fp, "P6\n%d %d\n255\n", f->width, f->height); + + for (int row = 0; row < f->height; row++) { + for (int col = 0; col < f->width; col++) { + uint8_t r, g, b; + + if (f->fmt == TEST_FMT_BGRA) { + const uint8_t *p = f->plane[0] + row * f->stride[0] + col * 4; + b = p[0]; g = p[1]; r = p[2]; + } else { + uint8_t luma = f->plane[0][row * f->stride[0] + col]; + uint8_t cb, cr; + if (f->fmt == TEST_FMT_YUV420) { + cb = f->plane[1][(row / 2) * f->stride[1] + col / 2]; + cr = f->plane[2][(row / 2) * f->stride[2] + col / 2]; + } else { /* YUV422 */ + cb = f->plane[1][row * f->stride[1] + col / 2]; + cr = f->plane[2][row * f->stride[2] + col / 2]; + } + ycbcr_to_rgb(luma, cb, cr, &r, &g, &b); + } + + uint8_t pix[3] = {r, g, b}; + fwrite(pix, 1, 3, fp); + } + } + + fclose(fp); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ + +static void usage(void) +{ + fprintf(stderr, + "usage: test_image_cli [--pattern bars|ramp|grid]\n" + " [--width N] [--height N]\n" + " [--format yuv420|yuv422|bgra]\n" + " --out FILE.ppm\n" + "\n" + "defaults: bars 1280x720 yuv420\n"); +} + +int main(int argc, char **argv) +{ + Test_Pattern pattern = TEST_PATTERN_BARS; + Test_Fmt fmt = TEST_FMT_YUV420; + int width = 1280; + int height = 720; + const char *out = NULL; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--pattern") == 0 && i + 1 < argc) { + i++; + if (strcmp(argv[i], "bars") == 0) { pattern = TEST_PATTERN_BARS; } + else if (strcmp(argv[i], "ramp") == 0) { pattern = TEST_PATTERN_RAMP; } + else if (strcmp(argv[i], "grid") == 0) { pattern = TEST_PATTERN_GRID; } + else { + fprintf(stderr, "unknown pattern: %s\n", argv[i]); + usage(); return 1; + } + } else if (strcmp(argv[i], "--width") == 0 && i + 1 < argc) { + width = atoi(argv[++i]); + } else if (strcmp(argv[i], "--height") == 0 && i + 1 < argc) { + height = atoi(argv[++i]); + } else if (strcmp(argv[i], "--format") == 0 && i + 1 < argc) { + i++; + if (strcmp(argv[i], "yuv420") == 0) { fmt = TEST_FMT_YUV420; } + else if (strcmp(argv[i], "yuv422") == 0) { fmt = TEST_FMT_YUV422; } + else if (strcmp(argv[i], "bgra") == 0) { fmt = TEST_FMT_BGRA; } + else { + fprintf(stderr, "unknown format: %s\n", argv[i]); + usage(); return 1; + } + } else if (strcmp(argv[i], "--out") == 0 && i + 1 < argc) { + out = argv[++i]; + } else { + usage(); return 1; + } + } + + if (!out) { + fprintf(stderr, "test_image_cli: --out FILE required\n"); + usage(); return 1; + } + if (width < 2 || height < 2) { + fprintf(stderr, "test_image_cli: width and height must be >= 2\n"); + return 1; + } + + Test_Frame *f = test_image_alloc(width, height, fmt); + if (!f) { + fprintf(stderr, "test_image_cli: allocation failed\n"); + return 1; + } + + test_image_generate(f, pattern); + + int rc = write_ppm(out, f); + test_image_free(f); + if (rc != 0) { return 1; } + + const char *fmt_name = fmt == TEST_FMT_YUV420 ? "yuv420" + : fmt == TEST_FMT_YUV422 ? "yuv422" + : "bgra"; + const char *pat_name = pattern == TEST_PATTERN_BARS ? "bars" + : pattern == TEST_PATTERN_RAMP ? "ramp" + : "grid"; + + printf("%s: %dx%d format=%s pattern=%s\n", out, width, height, fmt_name, pat_name); + return 0; +} diff --git a/include/test_image.h b/include/test_image.h new file mode 100644 index 0000000..2a7e0aa --- /dev/null +++ b/include/test_image.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +typedef enum Test_Pattern { + TEST_PATTERN_BARS, /* SMPTE 75% colour bars */ + TEST_PATTERN_RAMP, /* greyscale ramp, left to right */ + TEST_PATTERN_GRID, /* white grid lines on black */ +} Test_Pattern; + +typedef enum Test_Fmt { + TEST_FMT_YUV420, /* planar YUV 4:2:0 */ + TEST_FMT_YUV422, /* planar YUV 4:2:2 */ + TEST_FMT_BGRA, /* packed BGRA 8:8:8:8 */ +} Test_Fmt; + +typedef struct Test_Frame { + uint8_t *plane[3]; /* Y/Cb/Cr; only plane[0] used for BGRA */ + int stride[3]; /* bytes per row for each plane */ + int width; + int height; + Test_Fmt fmt; +} Test_Frame; + +/* + * Allocate a Test_Frame with correctly-sized plane buffers. + * Width and height must be >= 2 and even. + * Returns NULL on allocation failure. + */ +Test_Frame *test_image_alloc(int width, int height, Test_Fmt fmt); + +/* Fill all planes of f with the given pattern. */ +void test_image_generate(Test_Frame *f, Test_Pattern pat); + +void test_image_free(Test_Frame *f); diff --git a/include/xorg.h b/include/xorg.h new file mode 100644 index 0000000..7436005 --- /dev/null +++ b/include/xorg.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +typedef struct Xorg_Viewer Xorg_Viewer; + +/* Returns false when compiled without HAVE_GLFW. */ +bool xorg_available(void); + +/* + * Open a viewer window at screen position (x, y) with the given size. + * Returns NULL if xorg is unavailable or if window/context creation fails. + */ +Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height, + const char *title); + +/* Push a YUV 4:2:0 planar frame for immediate display. */ +bool xorg_viewer_push_yuv420(Xorg_Viewer *v, + const uint8_t *y, const uint8_t *cb, const uint8_t *cr, + int width, int height); + +/* Push a packed BGRA frame for immediate display. */ +bool xorg_viewer_push_bgra(Xorg_Viewer *v, + const uint8_t *data, int width, int height); + +/* + * Push a MJPEG frame. Decoded via libjpeg-turbo to planar YUV before upload. + * Returns false if compiled without HAVE_TURBOJPEG. + */ +bool xorg_viewer_push_mjpeg(Xorg_Viewer *v, + const uint8_t *data, size_t size); + +/* + * Process pending window events. + * Returns false when the user has closed the window. + * Must be called from the thread that created the viewer. + */ +bool xorg_viewer_poll(Xorg_Viewer *v); + +void xorg_viewer_close(Xorg_Viewer *v); diff --git a/src/modules/test_image/Makefile b/src/modules/test_image/Makefile new file mode 100644 index 0000000..0bd54ad --- /dev/null +++ b/src/modules/test_image/Makefile @@ -0,0 +1,19 @@ +ROOT := $(abspath ../../..) +include $(ROOT)/common.mk + +MODULE_BUILD = $(BUILD)/test_image + +.PHONY: all clean + +all: $(MODULE_BUILD)/test_image.o + +$(MODULE_BUILD)/test_image.o: test_image.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< + +$(MODULE_BUILD): + mkdir -p $@ + +clean: + rm -f $(MODULE_BUILD)/test_image.o $(MODULE_BUILD)/test_image.d + +-include $(MODULE_BUILD)/test_image.d diff --git a/src/modules/test_image/test_image.c b/src/modules/test_image/test_image.c new file mode 100644 index 0000000..9138c5d --- /dev/null +++ b/src/modules/test_image/test_image.c @@ -0,0 +1,179 @@ +#include +#include "test_image.h" + +/* ------------------------------------------------------------------ */ +/* BT.601 limited-range RGB → YCbCr */ +/* ------------------------------------------------------------------ */ + +static void rgb_to_ycbcr(int r, int g, int b, + uint8_t *y, uint8_t *cb, uint8_t *cr) +{ + *y = (uint8_t)((( 66*r + 129*g + 25*b + 128) >> 8) + 16); + *cb = (uint8_t)((( -38*r - 74*g + 112*b + 128) >> 8) + 128); + *cr = (uint8_t)((( 112*r - 94*g - 18*b + 128) >> 8) + 128); +} + +/* ------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------ */ + +Test_Frame *test_image_alloc(int width, int height, Test_Fmt fmt) +{ + Test_Frame *f = calloc(1, sizeof(*f)); + if (!f) { return NULL; } + + f->width = width; + f->height = height; + f->fmt = fmt; + + size_t buf_size; + + switch (fmt) { + case TEST_FMT_YUV420: + f->stride[0] = width; + f->stride[1] = width / 2; + f->stride[2] = width / 2; + buf_size = (size_t)width * height + + (size_t)(width / 2) * (height / 2) * 2; + break; + case TEST_FMT_YUV422: + f->stride[0] = width; + f->stride[1] = width / 2; + f->stride[2] = width / 2; + buf_size = (size_t)width * height + + (size_t)(width / 2) * height * 2; + break; + case TEST_FMT_BGRA: + f->stride[0] = width * 4; + f->stride[1] = 0; + f->stride[2] = 0; + buf_size = (size_t)width * height * 4; + break; + default: + free(f); + return NULL; + } + + uint8_t *buf = malloc(buf_size); + if (!buf) { free(f); return NULL; } + + f->plane[0] = buf; + f->plane[1] = NULL; + f->plane[2] = NULL; + + if (fmt == TEST_FMT_YUV420) { + f->plane[1] = buf + (size_t)width * height; + f->plane[2] = f->plane[1] + (size_t)(width / 2) * (height / 2); + } else if (fmt == TEST_FMT_YUV422) { + f->plane[1] = buf + (size_t)width * height; + f->plane[2] = f->plane[1] + (size_t)(width / 2) * height; + } + + return f; +} + +void test_image_free(Test_Frame *f) +{ + if (!f) { return; } + free(f->plane[0]); + free(f); +} + +/* ------------------------------------------------------------------ */ +/* Per-pixel write */ +/* ------------------------------------------------------------------ */ + +static void write_pixel(Test_Frame *f, int row, int col, int r, int g, int b) +{ + if (f->fmt == TEST_FMT_BGRA) { + uint8_t *p = f->plane[0] + row * f->stride[0] + col * 4; + p[0] = (uint8_t)b; + p[1] = (uint8_t)g; + p[2] = (uint8_t)r; + p[3] = 255; + return; + } + + uint8_t y, cb, cr; + rgb_to_ycbcr(r, g, b, &y, &cb, &cr); + + f->plane[0][row * f->stride[0] + col] = y; + + if (f->fmt == TEST_FMT_YUV420) { + if ((row & 1) == 0 && (col & 1) == 0) { + int ci = (row / 2) * f->stride[1] + col / 2; + f->plane[1][ci] = cb; + f->plane[2][ci] = cr; + } + } else { /* YUV422 */ + if ((col & 1) == 0) { + int ci = row * f->stride[1] + col / 2; + f->plane[1][ci] = cb; + f->plane[2][ci] = cr; + } + } +} + +/* ------------------------------------------------------------------ */ +/* Patterns */ +/* ------------------------------------------------------------------ */ + +#define N_BARS 8 + +static const int BAR_RGB[N_BARS][3] = { + {191, 191, 191}, /* white 75% */ + {191, 191, 0}, /* yellow */ + { 0, 191, 191}, /* cyan */ + { 0, 191, 0}, /* green */ + {191, 0, 191}, /* magenta */ + {191, 0, 0}, /* red */ + { 0, 0, 191}, /* blue */ + { 0, 0, 0}, /* black */ +}; + +static void gen_bars(Test_Frame *f) +{ + for (int row = 0; row < f->height; row++) { + for (int col = 0; col < f->width; col++) { + int bar = col * N_BARS / f->width; + write_pixel(f, row, col, + BAR_RGB[bar][0], BAR_RGB[bar][1], BAR_RGB[bar][2]); + } + } +} + +static void gen_ramp(Test_Frame *f) +{ + for (int row = 0; row < f->height; row++) { + for (int col = 0; col < f->width; col++) { + int v = (f->width > 1) ? col * 255 / (f->width - 1) : 128; + write_pixel(f, row, col, v, v, v); + } + } +} + +#define GRID_STEP 64 + +static void gen_grid(Test_Frame *f) +{ + for (int row = 0; row < f->height; row++) { + for (int col = 0; col < f->width; col++) { + int on = ((row % GRID_STEP) == 0) || ((col % GRID_STEP) == 0); + int v = on ? 255 : 0; + write_pixel(f, row, col, v, v, v); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Public */ +/* ------------------------------------------------------------------ */ + +void test_image_generate(Test_Frame *f, Test_Pattern pat) +{ + switch (pat) { + case TEST_PATTERN_BARS: gen_bars(f); break; + case TEST_PATTERN_RAMP: gen_ramp(f); break; + case TEST_PATTERN_GRID: gen_grid(f); break; + } +} diff --git a/src/modules/xorg/Makefile b/src/modules/xorg/Makefile new file mode 100644 index 0000000..e1dfc2a --- /dev/null +++ b/src/modules/xorg/Makefile @@ -0,0 +1,28 @@ +ROOT := $(abspath ../../..) +include $(ROOT)/common.mk + +MODULE_BUILD = $(BUILD)/xorg + +# Select real implementation when glfw feature is enabled, stub otherwise. +ifeq ($(filter glfw,$(FEATURES)),glfw) + SRC = xorg.c +else + SRC = xorg_stub.c +endif + +OBJ = $(MODULE_BUILD)/xorg.o + +.PHONY: all clean + +all: $(OBJ) + +$(OBJ): $(SRC) | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< + +$(MODULE_BUILD): + mkdir -p $@ + +clean: + rm -f $(OBJ) $(MODULE_BUILD)/xorg.d + +-include $(MODULE_BUILD)/xorg.d diff --git a/src/modules/xorg/xorg.c b/src/modules/xorg/xorg.c new file mode 100644 index 0000000..c4415ce --- /dev/null +++ b/src/modules/xorg/xorg.c @@ -0,0 +1,377 @@ +#include +#include +#include + +#include +#include + +#ifdef HAVE_TURBOJPEG +#include +#endif + +#include "xorg.h" + +/* ------------------------------------------------------------------ */ +/* Shader sources */ +/* ------------------------------------------------------------------ */ + +/* + * Full-screen quad via gl_VertexID — no VBO needed. + * UV is flipped on Y so that image row 0 appears at the top of the window + * (OpenGL texture origin is bottom-left; image data is stored top-row-first). + */ +static const char *VERT_SRC = + "#version 330 core\n" + "out vec2 v_uv;\n" + "void main() {\n" + " float x = float(gl_VertexID & 1) * 2.0 - 1.0;\n" + " float y = float((gl_VertexID >> 1) & 1) * 2.0 - 1.0;\n" + " v_uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n" + " gl_Position = vec4(x, y, 0.0, 1.0);\n" + "}\n"; + +/* BT.601 limited-range YCbCr → RGB. */ +static const char *FRAG_YUV_SRC = + "#version 330 core\n" + "in vec2 v_uv;\n" + "out vec4 out_color;\n" + "uniform sampler2D u_tex_y;\n" + "uniform sampler2D u_tex_cb;\n" + "uniform sampler2D u_tex_cr;\n" + "void main() {\n" + " float y = texture(u_tex_y, v_uv).r;\n" + " float cb = texture(u_tex_cb, v_uv).r - 0.5;\n" + " float cr = texture(u_tex_cr, v_uv).r - 0.5;\n" + " float r = clamp(1.164*(y - 0.0627) + 1.596*cr, 0.0, 1.0);\n" + " float g = clamp(1.164*(y - 0.0627) - 0.391*cb - 0.813*cr, 0.0, 1.0);\n" + " float b = clamp(1.164*(y - 0.0627) + 2.018*cb, 0.0, 1.0);\n" + " out_color = vec4(r, g, b, 1.0);\n" + "}\n"; + +/* Passthrough for BGRA — uploaded as GL_BGRA so driver swizzles to RGBA. */ +static const char *FRAG_RGB_SRC = + "#version 330 core\n" + "in vec2 v_uv;\n" + "out vec4 out_color;\n" + "uniform sampler2D u_tex_rgb;\n" + "void main() {\n" + " out_color = vec4(texture(u_tex_rgb, v_uv).rgb, 1.0);\n" + "}\n"; + +/* ------------------------------------------------------------------ */ +/* Viewer state */ +/* ------------------------------------------------------------------ */ + +struct Xorg_Viewer { + GLFWwindow *window; + + GLuint prog_yuv; + GLint u_tex_y, u_tex_cb, u_tex_cr; + + GLuint prog_rgb; + GLint u_tex_rgb; + + GLuint vao; + GLuint tex[4]; /* 0=Y 1=Cb 2=Cr 3=BGRA/RGB */ + +#ifdef HAVE_TURBOJPEG + tjhandle tj; + uint8_t *yuv_buf; + int tj_width, tj_height, tj_subsamp; +#endif +}; + +/* ------------------------------------------------------------------ */ +/* Shader helpers */ +/* ------------------------------------------------------------------ */ + +static GLuint compile_shader(GLenum type, const char *src) +{ + GLuint s = glCreateShader(type); + glShaderSource(s, 1, &src, NULL); + glCompileShader(s); + GLint ok; + glGetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[512]; + glGetShaderInfoLog(s, sizeof(log), NULL, log); + fprintf(stderr, "xorg: shader compile error: %s\n", log); + glDeleteShader(s); + return 0; + } + return s; +} + +static GLuint link_program(const char *frag_src) +{ + GLuint vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC); + GLuint fs = compile_shader(GL_FRAGMENT_SHADER, frag_src); + if (!vs || !fs) { + glDeleteShader(vs); + glDeleteShader(fs); + return 0; + } + GLuint p = glCreateProgram(); + glAttachShader(p, vs); + glAttachShader(p, fs); + glLinkProgram(p); + glDeleteShader(vs); + glDeleteShader(fs); + GLint ok; + glGetProgramiv(p, GL_LINK_STATUS, &ok); + if (!ok) { + char log[512]; + glGetProgramInfoLog(p, sizeof(log), NULL, log); + fprintf(stderr, "xorg: program link error: %s\n", log); + glDeleteProgram(p); + return 0; + } + return p; +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +bool xorg_available(void) { return true; } + +Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height, + const char *title) +{ + if (!glfwInit()) { + fprintf(stderr, "xorg: glfwInit failed\n"); + return NULL; + } + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + + GLFWwindow *win = glfwCreateWindow(width, height, title, NULL, NULL); + if (!win) { + fprintf(stderr, "xorg: glfwCreateWindow failed\n"); + glfwTerminate(); + return NULL; + } + glfwSetWindowPos(win, x, y); + glfwMakeContextCurrent(win); + glfwSwapInterval(1); /* vsync */ + + glewExperimental = GL_TRUE; + if (glewInit() != GLEW_OK) { + fprintf(stderr, "xorg: glewInit failed\n"); + glfwDestroyWindow(win); + glfwTerminate(); + return NULL; + } + + Xorg_Viewer *v = calloc(1, sizeof(*v)); + if (!v) { + glfwDestroyWindow(win); + glfwTerminate(); + return NULL; + } + v->window = win; + + v->prog_yuv = link_program(FRAG_YUV_SRC); + v->prog_rgb = link_program(FRAG_RGB_SRC); + if (!v->prog_yuv || !v->prog_rgb) { + xorg_viewer_close(v); + return NULL; + } + + v->u_tex_y = glGetUniformLocation(v->prog_yuv, "u_tex_y"); + v->u_tex_cb = glGetUniformLocation(v->prog_yuv, "u_tex_cb"); + v->u_tex_cr = glGetUniformLocation(v->prog_yuv, "u_tex_cr"); + v->u_tex_rgb = glGetUniformLocation(v->prog_rgb, "u_tex_rgb"); + + glGenVertexArrays(1, &v->vao); + glGenTextures(4, v->tex); + + for (int i = 0; i < 4; i++) { + glBindTexture(GL_TEXTURE_2D, v->tex[i]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + glBindTexture(GL_TEXTURE_2D, 0); + +#ifdef HAVE_TURBOJPEG + v->tj = tjInitDecompress(); + if (!v->tj) { + fprintf(stderr, "xorg: tjInitDecompress failed\n"); + xorg_viewer_close(v); + return NULL; + } +#endif + + return v; +} + +/* ------------------------------------------------------------------ */ +/* Internal: upload YUV planes and render */ +/* ------------------------------------------------------------------ */ + +static void upload_yuv(Xorg_Viewer *v, + const uint8_t *y, int y_w, int y_h, + const uint8_t *cb, int c_w, int c_h, + const uint8_t *cr) +{ + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + + glBindTexture(GL_TEXTURE_2D, v->tex[0]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, y_w, y_h, 0, + GL_RED, GL_UNSIGNED_BYTE, y); + + glBindTexture(GL_TEXTURE_2D, v->tex[1]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, c_w, c_h, 0, + GL_RED, GL_UNSIGNED_BYTE, cb); + + glBindTexture(GL_TEXTURE_2D, v->tex[2]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, c_w, c_h, 0, + GL_RED, GL_UNSIGNED_BYTE, cr); + + glBindTexture(GL_TEXTURE_2D, 0); + + int fb_w, fb_h; + glfwGetFramebufferSize(v->window, &fb_w, &fb_h); + glViewport(0, 0, fb_w, fb_h); + + glUseProgram(v->prog_yuv); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, v->tex[0]); + glUniform1i(v->u_tex_y, 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, v->tex[1]); + glUniform1i(v->u_tex_cb, 1); + + glActiveTexture(GL_TEXTURE2); + glBindTexture(GL_TEXTURE_2D, v->tex[2]); + glUniform1i(v->u_tex_cr, 2); + + glBindVertexArray(v->vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + + glfwSwapBuffers(v->window); +} + +/* ------------------------------------------------------------------ */ +/* Push functions */ +/* ------------------------------------------------------------------ */ + +bool xorg_viewer_push_yuv420(Xorg_Viewer *v, + const uint8_t *y, const uint8_t *cb, const uint8_t *cr, + int width, int height) +{ + if (!v) { return false; } + upload_yuv(v, y, width, height, cb, width / 2, height / 2, cr); + return true; +} + +bool xorg_viewer_push_bgra(Xorg_Viewer *v, + const uint8_t *data, int width, int height) +{ + if (!v) { return false; } + + glBindTexture(GL_TEXTURE_2D, v->tex[3]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, + GL_BGRA, GL_UNSIGNED_BYTE, data); + glBindTexture(GL_TEXTURE_2D, 0); + + int fb_w, fb_h; + glfwGetFramebufferSize(v->window, &fb_w, &fb_h); + glViewport(0, 0, fb_w, fb_h); + + glUseProgram(v->prog_rgb); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, v->tex[3]); + glUniform1i(v->u_tex_rgb, 0); + + glBindVertexArray(v->vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + + glfwSwapBuffers(v->window); + return true; +} + +bool xorg_viewer_push_mjpeg(Xorg_Viewer *v, + const uint8_t *data, size_t size) +{ +#ifndef HAVE_TURBOJPEG + (void)v; (void)data; (void)size; + return false; +#else + if (!v) { return false; } + + int w, h, subsamp, colorspace; + if (tjDecompressHeader3(v->tj, data, (unsigned long)size, + &w, &h, &subsamp, &colorspace) < 0) { + fprintf(stderr, "xorg: tjDecompressHeader3: %s\n", tjGetErrorStr2(v->tj)); + return false; + } + + /* Reallocate if dimensions or subsampling changed. */ + if (w != v->tj_width || h != v->tj_height || subsamp != v->tj_subsamp) { + free(v->yuv_buf); + v->yuv_buf = malloc(tjBufSizeYUV2(w, 1, h, subsamp)); + if (!v->yuv_buf) { return false; } + v->tj_width = w; + v->tj_height = h; + v->tj_subsamp = subsamp; + } + + int y_w = tjPlaneWidth(0, w, subsamp); + int y_h = tjPlaneHeight(0, h, subsamp); + int c_w = tjPlaneWidth(1, w, subsamp); + int c_h = tjPlaneHeight(1, h, subsamp); + + int strides[3] = { y_w, c_w, c_w }; + uint8_t *planes[3]; + planes[0] = v->yuv_buf; + planes[1] = planes[0] + y_w * y_h; + planes[2] = planes[1] + c_w * c_h; + + if (tjDecompressToYUVPlanes(v->tj, data, (unsigned long)size, + planes, w, strides, h, 0) < 0) { + fprintf(stderr, "xorg: tjDecompressToYUVPlanes: %s\n", tjGetErrorStr2(v->tj)); + return false; + } + + upload_yuv(v, planes[0], y_w, y_h, planes[1], c_w, c_h, planes[2]); + return true; +#endif +} + +/* ------------------------------------------------------------------ */ +/* Poll and close */ +/* ------------------------------------------------------------------ */ + +bool xorg_viewer_poll(Xorg_Viewer *v) +{ + if (!v || glfwWindowShouldClose(v->window)) { return false; } + glfwPollEvents(); + return !glfwWindowShouldClose(v->window); +} + +void xorg_viewer_close(Xorg_Viewer *v) +{ + if (!v) { return; } +#ifdef HAVE_TURBOJPEG + if (v->tj) { tjDestroy(v->tj); } + free(v->yuv_buf); +#endif + if (v->vao) { glDeleteVertexArrays(1, &v->vao); } + if (v->tex[0]) { glDeleteTextures(4, v->tex); } + if (v->prog_yuv) { glDeleteProgram(v->prog_yuv); } + if (v->prog_rgb) { glDeleteProgram(v->prog_rgb); } + if (v->window) { + glfwDestroyWindow(v->window); + glfwTerminate(); + } + free(v); +} diff --git a/src/modules/xorg/xorg_stub.c b/src/modules/xorg/xorg_stub.c new file mode 100644 index 0000000..a5fc619 --- /dev/null +++ b/src/modules/xorg/xorg_stub.c @@ -0,0 +1,36 @@ +#include +#include "xorg.h" + +bool xorg_available(void) { return false; } + +Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height, + const char *title) +{ + (void)x; (void)y; (void)width; (void)height; (void)title; + return NULL; +} + +bool xorg_viewer_push_yuv420(Xorg_Viewer *v, + const uint8_t *y, const uint8_t *cb, const uint8_t *cr, + int width, int height) +{ + (void)v; (void)y; (void)cb; (void)cr; (void)width; (void)height; + return false; +} + +bool xorg_viewer_push_bgra(Xorg_Viewer *v, + const uint8_t *data, int width, int height) +{ + (void)v; (void)data; (void)width; (void)height; + return false; +} + +bool xorg_viewer_push_mjpeg(Xorg_Viewer *v, + const uint8_t *data, size_t size) +{ + (void)v; (void)data; (void)size; + return false; +} + +bool xorg_viewer_poll(Xorg_Viewer *v) { (void)v; return false; } +void xorg_viewer_close(Xorg_Viewer *v) { (void)v; } diff --git a/src/node/Makefile b/src/node/Makefile index 4b343d8..edea59a 100644 --- a/src/node/Makefile +++ b/src/node/Makefile @@ -11,6 +11,7 @@ TRANSPORT_OBJ = $(BUILD)/transport/transport.o DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o CONFIG_OBJ = $(BUILD)/config/config.o PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o +XORG_OBJ = $(BUILD)/xorg/xorg.o .PHONY: all clean @@ -18,8 +19,9 @@ all: $(NODE_BUILD)/video-node $(NODE_BUILD)/video-node: $(MAIN_OBJ) \ $(COMMON_OBJ) $(MEDIA_OBJ) $(V4L2_OBJ) $(SERIAL_OBJ) \ - $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(CONFIG_OBJ) $(PROTOCOL_OBJ) - $(CC) $(CFLAGS) -o $@ $^ -lpthread + $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(CONFIG_OBJ) $(PROTOCOL_OBJ) \ + $(XORG_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread $(PKG_LDFLAGS) $(MAIN_OBJ): main.c | $(NODE_BUILD) $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< @@ -32,6 +34,7 @@ $(TRANSPORT_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/transport $(DISCOVERY_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/discovery $(CONFIG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/config $(PROTOCOL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/protocol +$(XORG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/xorg $(NODE_BUILD): mkdir -p $@