Add no-signal animation to display windows

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 19:20:53 +00:00
parent 7808d832be
commit 54d48c9c8e
7 changed files with 158 additions and 31 deletions

View File

@@ -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 <stream_id> [win_x] [win_y] [win_w] [win_h]\n");
printf("usage: start-display <stream_id> [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 <stream_id> <device> <dest_host> <dest_port>"
" [format] [width] [height] [fps_n] [fps_d]\n"
" stop-ingest <stream_id>\n"
" start-display <stream_id> [win_x] [win_y] [win_w] [win_h]\n"
" start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n"
" stop-display <stream_id>\n"
" help\n"
" quit / exit\n");

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -7,6 +7,7 @@
#include <pthread.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <time.h>
#include <sys/sysmacros.h>
#include <linux/videodev2.h>
@@ -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);