From 54d48c9c8e0f2918394f147246f47545ca7ffc14 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 29 Mar 2026 19:20:53 +0000 Subject: [PATCH] Add no-signal animation to display windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a viewer window has no incoming stream, renders animated analog-TV noise (hash-based, scanlines, phosphor tint) at configurable fps (default 15) with a centred "NO SIGNAL" text overlay. - xorg: FRAG_NOSIGNAL_SRC shader + xorg_viewer_render_no_signal(v, time, noise_res) - main: Display_Slot gains no_signal_fps + last_no_signal_t; display_loop_tick drives no-signal render on idle slots via clock_gettime rate limiting - protocol: START_DISPLAY extended by 2 bytes — no_signal_fps (0=default 15) + reserved; reader is backward-compatible (defaults 0 if length < 18) - controller_cli: no_signal_fps optional arg on start-display - docs: protocol.md updated with new field Co-Authored-By: Claude Sonnet 4.6 --- dev/cli/controller_cli.c | 25 ++++---- docs/protocol.md | 3 + include/protocol.h | 4 +- include/xorg.h | 8 +++ src/modules/protocol/protocol.c | 27 +++++---- src/modules/xorg/xorg.c | 100 ++++++++++++++++++++++++++++++-- src/node/main.c | 22 ++++++- 7 files changed, 158 insertions(+), 31 deletions(-) diff --git a/dev/cli/controller_cli.c b/dev/cli/controller_cli.c index 01f853e..eab6c5c 100644 --- a/dev/cli/controller_cli.c +++ b/dev/cli/controller_cli.c @@ -307,22 +307,25 @@ static void cmd_start_display(struct Transport_Conn *conn, int ntok, char *tokens[]) { /* Required: stream_id - * Optional: win_x win_y win_w win_h */ + * Optional: win_x win_y win_w win_h no_signal_fps */ if (ntok < 2) { - printf("usage: start-display [win_x] [win_y] [win_w] [win_h]\n"); + printf("usage: start-display [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n"); return; } - uint16_t stream_id = (uint16_t)atoi(tokens[1]); - int16_t win_x = ntok > 2 ? (int16_t)atoi(tokens[2]) : 0; - int16_t win_y = ntok > 3 ? (int16_t)atoi(tokens[3]) : 0; - uint16_t win_w = ntok > 4 ? (uint16_t)atoi(tokens[4]) : 0; - uint16_t win_h = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0; - printf("start-display: stream=%u pos=%d,%d size=%ux%u\n", - stream_id, win_x, win_y, win_w, win_h); + uint16_t stream_id = (uint16_t)atoi(tokens[1]); + int16_t win_x = ntok > 2 ? (int16_t)atoi(tokens[2]) : 0; + int16_t win_y = ntok > 3 ? (int16_t)atoi(tokens[3]) : 0; + uint16_t win_w = ntok > 4 ? (uint16_t)atoi(tokens[4]) : 0; + uint16_t win_h = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0; + uint8_t no_signal_fps = ntok > 6 ? (uint8_t)atoi(tokens[6]) : 0; + printf("start-display: stream=%u pos=%d,%d size=%ux%u no_signal_fps=%u\n", + stream_id, win_x, win_y, win_w, win_h, + no_signal_fps > 0 ? no_signal_fps : 15); SEND_AND_WAIT(cs, PROTO_CMD_START_DISPLAY, proto_write_start_display(conn, next_req_id(req), stream_id, win_x, win_y, win_w, win_h, - PROTO_DISPLAY_SCALE_FIT, PROTO_DISPLAY_ANCHOR_CENTER)); + PROTO_DISPLAY_SCALE_FIT, PROTO_DISPLAY_ANCHOR_CENTER, + no_signal_fps)); } static void cmd_stop_display(struct Transport_Conn *conn, @@ -345,7 +348,7 @@ static void cmd_help(void) " start-ingest " " [format] [width] [height] [fps_n] [fps_d]\n" " stop-ingest \n" - " start-display [win_x] [win_y] [win_w] [win_h]\n" + " start-display [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n" " stop-display \n" " help\n" " quit / exit\n"); diff --git a/docs/protocol.md b/docs/protocol.md index d49b607..4379e5d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -491,6 +491,8 @@ packet-beta 96-111: "win_h" 112-119: "scale" 120-127: "anchor" +128-135: "no_signal_fps" +136-143: "reserved" ``` | Field | Description | @@ -500,6 +502,7 @@ packet-beta | `win_w`, `win_h` | Window size in pixels; `0` = default (1280×720) | | `scale` | `0`=stretch `1`=fit `2`=fill `3`=1:1 | | `anchor` | `0`=center `1`=topleft | +| `no_signal_fps` | Frame rate of no-signal animation (0 = default 15 fps) | **Response** — no extra fields beyond request_id and status. `OK` means the display slot was reserved; the window opens asynchronously on the main thread. diff --git a/include/protocol.h b/include/protocol.h index 59ebb1e..0d0f4d2 100644 --- a/include/protocol.h +++ b/include/protocol.h @@ -251,6 +251,8 @@ struct Proto_Start_Display { uint16_t win_h; uint8_t scale; uint8_t anchor; + uint8_t no_signal_fps; /* 0 = default (15); no-signal animation frame rate */ + /* 1 byte reserved */ }; struct Proto_Stop_Display { @@ -340,7 +342,7 @@ struct App_Error proto_write_stop_ingest(struct Transport_Conn *conn, struct App_Error proto_write_start_display(struct Transport_Conn *conn, uint16_t request_id, uint16_t stream_id, int16_t win_x, int16_t win_y, uint16_t win_w, uint16_t win_h, - uint8_t scale, uint8_t anchor); + uint8_t scale, uint8_t anchor, uint8_t no_signal_fps); /* CONTROL_REQUEST: STOP_DISPLAY */ struct App_Error proto_write_stop_display(struct Transport_Conn *conn, diff --git a/include/xorg.h b/include/xorg.h index c956bf8..230a677 100644 --- a/include/xorg.h +++ b/include/xorg.h @@ -67,6 +67,14 @@ void xorg_viewer_set_overlay_text(Xorg_Viewer *v, int idx, int x, int y, /* Remove all text overlays. */ void xorg_viewer_clear_overlays(Xorg_Viewer *v); +/* + * Render one frame of animated analog-TV noise with a centred "NO SIGNAL" + * label. time is seconds (e.g. glfwGetTime()); noise_res is cells per axis + * (lower = coarser, default 80 when 0 is passed). + * Call at low frame rate (~15 fps) when the viewer has no incoming stream. + */ +void xorg_viewer_render_no_signal(Xorg_Viewer *v, float time, float noise_res); + /* * Process pending window events. * Returns false when the user has closed the window. diff --git a/src/modules/protocol/protocol.c b/src/modules/protocol/protocol.c index 23d27c6..b53fefd 100644 --- a/src/modules/protocol/protocol.c +++ b/src/modules/protocol/protocol.c @@ -602,13 +602,13 @@ struct App_Error proto_read_stop_ingest( } /* START_DISPLAY: request_id(2) cmd(2) stream_id(2) win_x(2) win_y(2) - * win_w(2) win_h(2) scale(1) anchor(1) = 16 bytes */ + * win_w(2) win_h(2) scale(1) anchor(1) no_signal_fps(1) reserved(1) = 18 bytes */ struct App_Error proto_write_start_display(struct Transport_Conn *conn, uint16_t request_id, uint16_t stream_id, int16_t win_x, int16_t win_y, uint16_t win_w, uint16_t win_h, - uint8_t scale, uint8_t anchor) + uint8_t scale, uint8_t anchor, uint8_t no_signal_fps) { - uint8_t buf[16]; + uint8_t buf[18]; uint32_t o = 0; put_u16(buf, o, request_id); o += 2; put_u16(buf, o, PROTO_CMD_START_DISPLAY); o += 2; @@ -619,8 +619,10 @@ struct App_Error proto_write_start_display(struct Transport_Conn *conn, put_u16(buf, o, win_h); o += 2; put_u8 (buf, o, scale); o += 1; put_u8 (buf, o, anchor); o += 1; + put_u8 (buf, o, no_signal_fps); o += 1; + put_u8 (buf, o, 0); o += 1; /* reserved */ (void)o; - return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 16); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 18); } struct App_Error proto_write_stop_display(struct Transport_Conn *conn, @@ -638,15 +640,16 @@ struct App_Error proto_read_start_display( struct Proto_Start_Display *out) { if (length < 16) { return APP_INVALID_ERROR_MSG(0, "START_DISPLAY payload too short"); } - out->request_id = get_u16(payload, 0); + out->request_id = get_u16(payload, 0); /* skip command word at [2..3] */ - out->stream_id = get_u16(payload, 4); - out->win_x = get_i16(payload, 6); - out->win_y = get_i16(payload, 8); - out->win_w = get_u16(payload, 10); - out->win_h = get_u16(payload, 12); - out->scale = get_u8 (payload, 14); - out->anchor = get_u8 (payload, 15); + out->stream_id = get_u16(payload, 4); + out->win_x = get_i16(payload, 6); + out->win_y = get_i16(payload, 8); + out->win_w = get_u16(payload, 10); + out->win_h = get_u16(payload, 12); + out->scale = get_u8 (payload, 14); + out->anchor = get_u8 (payload, 15); + out->no_signal_fps = length >= 18 ? get_u8(payload, 16) : 0; return APP_OK; } diff --git a/src/modules/xorg/xorg.c b/src/modules/xorg/xorg.c index e7a9ebf..6317258 100644 --- a/src/modules/xorg/xorg.c +++ b/src/modules/xorg/xorg.c @@ -69,6 +69,31 @@ static const char *FRAG_YUV_SRC = " out_color = vec4(r, g, b, 1.0);\n" "}\n"; +/* + * Animated analog-TV noise — no textures, driven by u_time. + * u_noise_res: cells per axis (lower = coarser pixels). Default ~80. + * Uses the same VERT_SRC quad; u_uv_scale / u_uv_offset set to identity. + */ +static const char *FRAG_NOSIGNAL_SRC = + "#version 330 core\n" + "in vec2 v_uv;\n" + "out vec4 out_color;\n" + "uniform float u_time;\n" + "uniform float u_noise_res;\n" + "float hash(vec2 p) {\n" + " p = fract(p * vec2(127.1, 311.7));\n" + " p += dot(p, p + 19.19);\n" + " return fract(p.x * p.y);\n" + "}\n" + "void main() {\n" + " vec2 cell = floor(v_uv * u_noise_res) / u_noise_res;\n" + " float tick = floor(u_time * 30.0);\n" + " float n = hash(cell + tick * 0.017);\n" + " float scan = 0.78 + 0.22 * sin(v_uv.y * u_noise_res * 6.2832 * 3.0);\n" + " float luma = n * scan;\n" + " out_color = vec4(luma * 0.72, luma * 0.80, luma * 0.62, 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" @@ -189,6 +214,13 @@ struct Xorg_Viewer { Xorg_Anchor anchor; int frame_w, frame_h; + /* No-signal noise */ + GLuint prog_nosignal; + GLint u_nosignal_uv_scale; + GLint u_nosignal_uv_offset; + GLint u_nosignal_time; + GLint u_nosignal_res; + /* Solid rect (overlay background) */ GLuint prog_rect; GLint u_rect_loc; @@ -397,6 +429,13 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height, v->u_uv_scale_rgb = glGetUniformLocation(v->prog_rgb, "u_uv_scale"); v->u_uv_offset_rgb= glGetUniformLocation(v->prog_rgb, "u_uv_offset"); + v->prog_nosignal = link_program(VERT_SRC, FRAG_NOSIGNAL_SRC); + if (!v->prog_nosignal) { xorg_viewer_close(v); return NULL; } + v->u_nosignal_uv_scale = glGetUniformLocation(v->prog_nosignal, "u_uv_scale"); + v->u_nosignal_uv_offset = glGetUniformLocation(v->prog_nosignal, "u_uv_offset"); + v->u_nosignal_time = glGetUniformLocation(v->prog_nosignal, "u_time"); + v->u_nosignal_res = glGetUniformLocation(v->prog_nosignal, "u_noise_res"); + glGenVertexArrays(1, &v->vao); glGenTextures(4, v->tex); @@ -817,6 +856,58 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v, #endif } +/* ------------------------------------------------------------------ */ +/* No-signal screen */ +/* ------------------------------------------------------------------ */ + +/* + * Render one frame of animated analog-TV noise with a centred "NO SIGNAL" + * label. time is seconds (e.g. from glfwGetTime()); noise_res is cells + * per axis — lower = coarser pixels (default: 80). + * Call at ~15 fps when the viewer has no live stream to display. + */ +void xorg_viewer_render_no_signal(Xorg_Viewer *v, float time, float noise_res) +{ + if (!v) { return; } + glfwMakeContextCurrent(v->window); + + int fb_w, fb_h; + glfwGetFramebufferSize(v->window, &fb_w, &fb_h); + + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glViewport(0, 0, fb_w, fb_h); + + glUseProgram(v->prog_nosignal); + glUniform2f(v->u_nosignal_uv_scale, 1.0f, 1.0f); + glUniform2f(v->u_nosignal_uv_offset, 0.0f, 0.0f); + glUniform1f(v->u_nosignal_time, time); + glUniform1f(v->u_nosignal_res, noise_res > 0.0f ? noise_res : 80.0f); + + glBindVertexArray(v->vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + + /* Measure "NO SIGNAL" text width to centre it. */ + const char *label = "NO SIGNAL"; + int text_w = 0, max_ascent = 0; + for (const char *p = label; *p; p++) { + unsigned char cp = (unsigned char)*p; + text_w += font_glyphs[cp].advance; + if (font_glyphs[cp].bearing_y > max_ascent) { + max_ascent = font_glyphs[cp].bearing_y; + } + } + int tx = (fb_w - text_w) / 2; + int ty = (fb_h - max_ascent) / 2; + + xorg_viewer_set_overlay_text(v, 0, tx, ty, label, 1.0f, 1.0f, 1.0f); + draw_text_overlays(v, fb_w, fb_h); + xorg_viewer_clear_overlays(v); + + glfwSwapBuffers(v->window); +} + /* ------------------------------------------------------------------ */ /* Poll and close */ /* ------------------------------------------------------------------ */ @@ -850,10 +941,11 @@ void xorg_viewer_close(Xorg_Viewer *v) if (v->tex_atlas) { glDeleteTextures(1, &v->tex_atlas); } if (v->prog_text) { glDeleteProgram(v->prog_text); } if (v->prog_rect) { glDeleteProgram(v->prog_rect); } - 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->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->prog_nosignal) { glDeleteProgram(v->prog_nosignal); } if (v->window) { glfwDestroyWindow(v->window); glfw_release(); diff --git a/src/node/main.c b/src/node/main.c index adb6979..c387edd 100644 --- a/src/node/main.c +++ b/src/node/main.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -183,6 +184,10 @@ struct Display_Slot { uint32_t frame_len; int frame_ready; + /* No-signal animation */ + int no_signal_fps; /* 0 → default 15 */ + double last_no_signal_t; + /* Viewer — created and used only on the main thread */ Xorg_Viewer *viewer; }; @@ -521,6 +526,16 @@ static void display_loop_tick(struct Node *node) xorg_viewer_push_mjpeg(d->viewer, vf.data, vf.data_len); } free(fdata); + } else { + /* No live frame — render no-signal animation at configured fps */ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + double now = (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; + double interval = 1.0 / (double)(d->no_signal_fps > 0 ? d->no_signal_fps : 15); + if (now - d->last_no_signal_t >= interval) { + xorg_viewer_render_no_signal(d->viewer, (float)now, 80.0f); + d->last_no_signal_t = now; + } } /* Poll GLFW events; if user closes the window, treat as STOP_DISPLAY */ @@ -1083,9 +1098,10 @@ static void handle_start_display(struct Node *node, d->win_y = (int)req.win_y; d->win_w = req.win_w > 0 ? (int)req.win_w : 1280; d->win_h = req.win_h > 0 ? (int)req.win_h : 720; - d->scale = proto_scale_to_xorg(req.scale); - d->anchor = proto_anchor_to_xorg(req.anchor); - d->wanted_state = DISP_OPEN; /* reconciled by display_loop_tick */ + d->scale = proto_scale_to_xorg(req.scale); + d->anchor = proto_anchor_to_xorg(req.anchor); + d->no_signal_fps = req.no_signal_fps > 0 ? (int)req.no_signal_fps : 15; + d->wanted_state = DISP_OPEN; /* reconciled by display_loop_tick */ pthread_mutex_unlock(&d->mutex); proto_write_control_response(conn, req.request_id, PROTO_STATUS_OK, NULL, 0);