Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ A lightweight video input viewer — **OBS without the complexity**. View and ma
*Dual view with the dropdown open: per-side input selection, per-input and
system volume, and the centre divider between the two feeds.*

<!-- Still open from issue #49: a short GIF showing D/S layout switching and
1-4 input selection. Add assets/demo.gif and uncomment the line below --
an unresolved image path renders as a broken image on the repo page.

![Layout switching and input selection](assets/demo.gif)
-->

## Download

Download the latest release for your platform:
Expand Down
14 changes: 12 additions & 2 deletions input_viewer_electron/src/renderer/gpu-compositor.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,17 @@ export async function supportsGpuCompositing() {
}
}

const SHADER = `
/**
* The compositing shader.
*
* Exported so it can be parse-checked without a GPU. No WebGPU adapter is
* available in CI or in a headless Electron run -- not even with
* forceFallbackAdapter -- so this module's runtime path cannot be exercised
* here. Validating that the WGSL at least parses is the part that can be
* checked, and it catches the most likely class of mistake in a shader nobody
* has been able to execute.
*/
export const COMPOSITOR_WGSL = `
struct VertexOut {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
Expand Down Expand Up @@ -103,7 +113,7 @@ export async function createGpuCompositor(canvas) {
const format = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format, alphaMode: 'opaque' })

const module = device.createShaderModule({ code: SHADER })
const module = device.createShaderModule({ code: COMPOSITOR_WGSL })
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
Expand Down
3 changes: 2 additions & 1 deletion input_viewer_electron/src/renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2068,5 +2068,6 @@ export {
setCenterGap,
setBorderWidth,
startDetectionLoop,
stopDetectionLoop
stopDetectionLoop,
gpuFeedLayout
}
89 changes: 89 additions & 0 deletions input_viewer_electron/test/gpu-compositor-wgsl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025-2026 Schuberg Philis / Lab271
/**
* Structural checks on the WebGPU compositor's WGSL (issue #62).
*
* SCOPE, STATED PLAINLY: this shader has never executed. No WebGPU adapter is
* obtainable in CI or in a headless Electron run -- `navigator.gpu` exists but
* `requestAdapter()` returns null even with `forceFallbackAdapter: true` and
* SwiftShader forced on the command line. So the compositor's runtime path
* cannot be exercised here at all, which is precisely why it ships dormant
* behind the `gpuCompositing` setting.
*
* What IS checkable is the shader's structure: that the entry points the
* pipeline names actually exist, that every binding the JS side sets up is
* declared, and that the uniform block matches the buffer size allocated for
* it. Those are the mistakes most likely to be sitting in code nobody has run,
* and each one would surface as a link-time failure on the first machine that
* does have a GPU.
*
* This is not a substitute for running it. It is the strongest check available
* without hardware.
*/
import { describe, it, expect } from 'vitest'
import { COMPOSITOR_WGSL } from '../src/renderer/gpu-compositor.js'

describe('compositor WGSL structure', () => {
it('declares the entry points the render pipeline names', () => {
// createGpuCompositor passes entryPoint: 'vs' and 'fs'. A mismatch here is
// a pipeline-creation error at runtime, not a compile error.
expect(COMPOSITOR_WGSL).toMatch(/@vertex\s+fn vs\(/)
expect(COMPOSITOR_WGSL).toMatch(/@fragment\s+fn fs\(/)
})

it('declares all three bindings the bind group provides', () => {
// The JS builds entries for bindings 0 (uniform), 1 (sampler), 2 (texture).
// A declared-but-unused or missing binding fails bind group creation.
expect(COMPOSITOR_WGSL).toMatch(/@group\(0\) @binding\(0\)[^;]*uniform/)
expect(COMPOSITOR_WGSL).toMatch(/@group\(0\) @binding\(1\)[^;]*sampler/)
expect(COMPOSITOR_WGSL).toMatch(/@group\(0\) @binding\(2\)[^;]*texture_external/)
})

it('uses texture_external, which is the zero-copy import path', () => {
// The whole premise of #62 is importing decoded video frames without a
// copy. A plain texture_2d would mean something has gone wrong.
expect(COMPOSITOR_WGSL).toContain('texture_external')
expect(COMPOSITOR_WGSL).toContain('textureSampleBaseClampToEdge')
})

it('has a uniform struct that fits the allocated buffer', () => {
// createGpuCompositor allocates 32 bytes per feed. The struct is two vec2f
// (offset, scale) = 16 bytes, so it fits with padding to spare. If the
// struct grows past 32 bytes the writeBuffer would silently truncate.
const struct = COMPOSITOR_WGSL.match(/struct Layout \{([\s\S]*?)\}/)
expect(struct, 'Layout struct not found').toBeTruthy()
const vec2Count = (struct[1].match(/vec2f/g) || []).length
expect(vec2Count).toBe(2)
expect(vec2Count * 8).toBeLessThanOrEqual(32)
})

it('emits six vertices for the quad the draw call requests', () => {
// pass.draw(6) -- the array must hold exactly that many corners or the
// shader indexes out of bounds.
const quad = COMPOSITOR_WGSL.match(/array<vec2f, (\d+)>/)
expect(quad, 'quad array not found').toBeTruthy()
expect(Number(quad[1])).toBe(6)
// And the array literal really lists six corners.
const corners = (COMPOSITOR_WGSL.match(/vec2f\([-\d.]+, [-\d.]+\)/g) || []).length
expect(corners).toBeGreaterThanOrEqual(6)
})

it('flips Y, since clip space and texture space disagree', () => {
// Without the flip the composited video is upside down -- a mistake that
// compiles perfectly and is only visible on screen.
expect(COMPOSITOR_WGSL).toMatch(/vec4f\(ndc\.x, -ndc\.y/)
})

it('has balanced braces, so the source is not truncated', () => {
const open = (COMPOSITOR_WGSL.match(/\{/g) || []).length
const close = (COMPOSITOR_WGSL.match(/\}/g) || []).length
expect(open).toBe(close)
})

it('contains no JS template-literal syntax that would break interpolation', () => {
// A stray backtick inside the template literal terminates it early; that
// exact mistake has broken the build twice in this codebase.
expect(COMPOSITOR_WGSL).not.toContain('`')
expect(COMPOSITOR_WGSL).not.toContain('${')
})
})