feat: xorg viewer scale modes, resize fix, arch notes

Scale modes (STRETCH/FIT/FILL/1:1) with CENTER/TOP_LEFT anchor:
- UV crop via u_uv_scale/u_uv_offset uniforms in vertex shader
- glViewport sub-rect + glClear for FIT and 1:1 modes
- xorg_viewer_set_scale() / xorg_viewer_set_anchor() setters
- Stub implementations for both

Resize fix: glfwSetWindowUserPointer + framebuffer_size_callback calls
render() synchronously during resize so image tracks window edge
immediately. Forward declaration added to fix implicit decl error.

Q/Escape close the window via key_callback.

xorg_cli: --scale and --anchor arguments added.

architecture.md:
- Scale mode table and anchor docs in Frame Viewer Sink section
- Render loop design note: frame-driven not timer-driven, resize callback
  rationale, threading note (GL context ownership, frame queue)
- Text overlay section: tier 1 bitmap atlas (Pillow build tool, skyline
  packing, quad rendering), tier 2 HarfBuzz+FreeType, migration path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 21:30:28 +00:00
parent ef0319b45b
commit 7fd79e6120
6 changed files with 413 additions and 56 deletions

View File

@@ -16,17 +16,20 @@
/* ------------------------------------------------------------------ */
/*
* 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).
* Full-screen quad via gl_VertexID.
* u_uv_scale / u_uv_offset let render() apply UV crop for FILL and 1:1 modes.
* UV Y is flipped: image row 0 appears at the top of the window.
*/
static const char *VERT_SRC =
"#version 330 core\n"
"out vec2 v_uv;\n"
"uniform vec2 u_uv_scale;\n"
"uniform vec2 u_uv_offset;\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"
" vec2 uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n"
" v_uv = uv * u_uv_scale + u_uv_offset;\n"
" gl_Position = vec4(x, y, 0.0, 1.0);\n"
"}\n";
@@ -62,17 +65,26 @@ static const char *FRAG_RGB_SRC =
/* Viewer state */
/* ------------------------------------------------------------------ */
typedef enum { MODE_NONE, MODE_YUV, MODE_RGB } Render_Mode;
struct Xorg_Viewer {
GLFWwindow *window;
GLFWwindow *window;
GLuint prog_yuv;
GLint u_tex_y, u_tex_cb, u_tex_cr;
GLint u_uv_scale_yuv, u_uv_offset_yuv;
GLuint prog_rgb;
GLint u_tex_rgb;
GLint u_uv_scale_rgb, u_uv_offset_rgb;
GLuint vao;
GLuint tex[4]; /* 0=Y 1=Cb 2=Cr 3=BGRA/RGB */
GLuint vao;
GLuint tex[4]; /* 0=Y 1=Cb 2=Cr 3=BGRA/RGB */
Render_Mode mode;
Xorg_Scale scale;
Xorg_Anchor anchor;
int frame_w, frame_h;
#ifdef HAVE_TURBOJPEG
tjhandle tj;
@@ -133,8 +145,27 @@ static GLuint link_program(const char *frag_src)
/* Public API */
/* ------------------------------------------------------------------ */
static void render(Xorg_Viewer *v); /* forward declaration */
bool xorg_available(void) { return true; }
static void key_callback(GLFWwindow *window, int key, int scancode,
int action, int mods)
{
(void)scancode; (void)mods;
if (action == GLFW_PRESS &&
(key == GLFW_KEY_ESCAPE || key == GLFW_KEY_Q)) {
glfwSetWindowShouldClose(window, GLFW_TRUE);
}
}
static void framebuffer_size_callback(GLFWwindow *window, int width, int height)
{
(void)width; (void)height;
Xorg_Viewer *v = glfwGetWindowUserPointer(window);
if (v) { render(v); }
}
Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
const char *title)
{
@@ -154,8 +185,9 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
return NULL;
}
glfwSetWindowPos(win, x, y);
glfwSetKeyCallback(win, key_callback);
glfwMakeContextCurrent(win);
glfwSwapInterval(1); /* vsync */
glfwSwapInterval(1);
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK) {
@@ -172,6 +204,10 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
return NULL;
}
v->window = win;
v->scale = XORG_SCALE_STRETCH;
v->anchor = XORG_ANCHOR_CENTER;
glfwSetWindowUserPointer(win, v);
glfwSetFramebufferSizeCallback(win, framebuffer_size_callback);
v->prog_yuv = link_program(FRAG_YUV_SRC);
v->prog_rgb = link_program(FRAG_RGB_SRC);
@@ -180,10 +216,15 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
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");
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_uv_scale_yuv = glGetUniformLocation(v->prog_yuv, "u_uv_scale");
v->u_uv_offset_yuv= glGetUniformLocation(v->prog_yuv, "u_uv_offset");
v->u_tex_rgb = glGetUniformLocation(v->prog_rgb, "u_tex_rgb");
v->u_uv_scale_rgb = glGetUniformLocation(v->prog_rgb, "u_uv_scale");
v->u_uv_offset_rgb= glGetUniformLocation(v->prog_rgb, "u_uv_offset");
glGenVertexArrays(1, &v->vao);
glGenTextures(4, v->tex);
@@ -209,8 +250,144 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
return v;
}
void xorg_viewer_set_scale(Xorg_Viewer *v, Xorg_Scale scale)
{
if (v) { v->scale = scale; }
}
void xorg_viewer_set_anchor(Xorg_Viewer *v, Xorg_Anchor anchor)
{
if (v) { v->anchor = anchor; }
}
/* ------------------------------------------------------------------ */
/* Internal: upload YUV planes and render */
/* Internal: compute layout and render from existing textures */
/* ------------------------------------------------------------------ */
static void render(Xorg_Viewer *v)
{
if (v->mode == MODE_NONE) { return; }
int fb_w, fb_h;
glfwGetFramebufferSize(v->window, &fb_w, &fb_h);
int vp_x = 0, vp_y = 0, vp_w = fb_w, vp_h = fb_h;
float uv_sx = 1.0f, uv_sy = 1.0f;
float uv_ox = 0.0f, uv_oy = 0.0f;
int fw = v->frame_w, fh = v->frame_h;
if (fw > 0 && fh > 0 && v->scale != XORG_SCALE_STRETCH) {
float fa = (float)fw / fh;
float wa = (float)fb_w / fb_h;
switch (v->scale) {
case XORG_SCALE_FIT:
/* Largest rect that fits; black bars fill the rest. */
if (fa > wa) {
vp_w = fb_w;
vp_h = (int)(fb_w / fa);
} else {
vp_h = fb_h;
vp_w = (int)(fb_h * fa);
}
vp_x = (v->anchor == XORG_ANCHOR_CENTER) ? (fb_w - vp_w) / 2 : 0;
vp_y = (v->anchor == XORG_ANCHOR_CENTER) ? (fb_h - vp_h) / 2
: fb_h - vp_h;
break;
case XORG_SCALE_FILL:
/*
* Scale to cover the window; crop the overflowing axis.
* UV range is narrowed on the cropped axis.
*/
if (fa > wa) {
/* Frame wider than window: fit height, crop width. */
uv_sx = wa / fa;
uv_ox = (v->anchor == XORG_ANCHOR_CENTER)
? (1.0f - uv_sx) * 0.5f : 0.0f;
} else {
/* Frame taller than window: fit width, crop height. */
uv_sy = fa / wa;
uv_oy = (v->anchor == XORG_ANCHOR_CENTER)
? (1.0f - uv_sy) * 0.5f : 0.0f;
}
break;
case XORG_SCALE_1_1:
/*
* One frame pixel = one screen pixel.
* If frame is larger than the window, the excess is cropped.
*/
vp_w = fw < fb_w ? fw : fb_w;
vp_h = fh < fb_h ? fh : fb_h;
if (v->anchor == XORG_ANCHOR_CENTER) {
vp_x = (fb_w - fw) / 2;
vp_y = (fb_h - fh) / 2;
/* If frame overflows, crop from centre via UV offset. */
if (vp_x < 0) {
uv_ox = (float)(-vp_x) / fw;
uv_sx = (float)vp_w / fw;
vp_x = 0;
}
if (vp_y < 0) {
uv_oy = (float)(-vp_y) / fh;
uv_sy = (float)vp_h / fh;
vp_y = 0;
}
} else {
/* Top-left anchor: show top-left portion of frame. */
vp_x = 0;
vp_y = fb_h - vp_h;
uv_sx = (float)vp_w / fw;
uv_sy = (float)vp_h / fh;
}
break;
case XORG_SCALE_STRETCH:
break;
}
}
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glViewport(vp_x, vp_y, vp_w, vp_h);
GLuint prog = (v->mode == MODE_YUV) ? v->prog_yuv : v->prog_rgb;
GLint u_uv_scale = (v->mode == MODE_YUV) ? v->u_uv_scale_yuv : v->u_uv_scale_rgb;
GLint u_uv_off = (v->mode == MODE_YUV) ? v->u_uv_offset_yuv : v->u_uv_offset_rgb;
glUseProgram(prog);
glUniform2f(u_uv_scale, uv_sx, uv_sy);
glUniform2f(u_uv_off, uv_ox, uv_oy);
if (v->mode == MODE_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);
} else {
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);
}
/* ------------------------------------------------------------------ */
/* Internal: upload YUV planes */
/* ------------------------------------------------------------------ */
static void upload_yuv(Xorg_Viewer *v,
@@ -234,29 +411,8 @@ static void upload_yuv(Xorg_Viewer *v,
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);
v->mode = MODE_YUV;
render(v);
}
/* ------------------------------------------------------------------ */
@@ -268,6 +424,8 @@ bool xorg_viewer_push_yuv420(Xorg_Viewer *v,
int width, int height)
{
if (!v) { return false; }
v->frame_w = width;
v->frame_h = height;
upload_yuv(v, y, width, height, cb, width / 2, height / 2, cr);
return true;
}
@@ -277,25 +435,16 @@ bool xorg_viewer_push_bgra(Xorg_Viewer *v,
{
if (!v) { return false; }
v->frame_w = width;
v->frame_h = height;
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);
v->mode = MODE_RGB;
render(v);
return true;
}
@@ -315,7 +464,6 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
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));
@@ -342,6 +490,8 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
return false;
}
v->frame_w = w;
v->frame_h = h;
upload_yuv(v, planes[0], y_w, y_h, planes[1], c_w, c_h, planes[2]);
return true;
#endif
@@ -355,7 +505,9 @@ bool xorg_viewer_poll(Xorg_Viewer *v)
{
if (!v || glfwWindowShouldClose(v->window)) { return false; }
glfwPollEvents();
return !glfwWindowShouldClose(v->window);
if (glfwWindowShouldClose(v->window)) { return false; }
render(v);
return true;
}
void xorg_viewer_close(Xorg_Viewer *v)

View File

@@ -32,5 +32,7 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
return false;
}
void xorg_viewer_set_scale(Xorg_Viewer *v, Xorg_Scale scale) { (void)v; (void)scale; }
void xorg_viewer_set_anchor(Xorg_Viewer *v, Xorg_Anchor anchor) { (void)v; (void)anchor; }
bool xorg_viewer_poll(Xorg_Viewer *v) { (void)v; return false; }
void xorg_viewer_close(Xorg_Viewer *v) { (void)v; }