diff --git a/server/player/index.html b/server/player/index.html index c6af824..7366d68 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -3395,6 +3395,45 @@ catch (e) { return false; } } + // Did drawImage actually put pixels on the canvas, or did it silently draw nothing? + // + // isMediaReadable() above answers "are we ALLOWED to read this" (same-origin / CORS). It does + // not answer "did any pixels arrive", and those are different questions. On BrightSign with + // hwz enabled, video decodes onto a HARDWARE PLANE outside the browser compositor — per + // BrightSign's own docs the HTML/JS layer "doesn't see the pixels" — so drawImage(video) + // yields a fully TRANSPARENT image and throws nothing. Chromium 87 fails the same way. + // + // The old code set captured/drawn = true purely because drawMediaFit() had not thrown, so a + // BrightSign emitted a BLANK screenshot and logged success: the dashboard showed a dead screen + // while the panel was playing perfectly. That is the "reports success and changes nothing" + // shape, and it is why this probes instead of assuming. + // + // ALPHA is the discriminator, not colour. A scratch canvas starts fully transparent, and a real + // decoded frame writes alpha=255 even when the frame is pure black — so a legitimate + // fade-to-black still reads as captured, while "nothing arrived" reads as not captured. + function videoFrameIsCapturable(video) { + if (!video) return false; + let pctx; + try { + const probe = document.createElement('canvas'); + probe.width = 16; probe.height = 16; // a 16x16 probe at 1fps is free; no full-size draw + pctx = probe.getContext('2d'); + if (!pctx) return false; + pctx.drawImage(video, 0, 0, 16, 16); + } catch (e) { + return false; // the draw itself failed - nothing landed + } + try { + const data = pctx.getImageData(0, 0, 16, 16).data; + for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return true; + return false; + } catch (e) { + // SecurityError means the canvas was TAINTED, and tainting only happens once cross-origin + // pixels have actually been drawn. So this is evidence of success, not failure. + return true; + } + } + function zonePlaceholderLabel(el) { if (!el) return 'Live'; if (el.tagName === 'IFRAME') { @@ -3448,7 +3487,15 @@ if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0 && isMediaReadable(el)) { try { drawMediaFit(ctx, el, el.naturalWidth, el.naturalHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {} } else if (el && el.tagName === 'VIDEO' && el.readyState >= 2 && el.videoWidth > 0 && isMediaReadable(el)) { - try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {} + // Probe FIRST. On a hardware video plane the draw succeeds and paints nothing, which + // would leave a black rectangle here instead of the labelled placeholder this function + // was written to guarantee ("never a transparent hole"). + if (videoFrameIsCapturable(el)) { + try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {} + } else { + drawZonePlaceholder(ctx, dx, dy, dw, dh, 'Video (not capturable)'); + drawn = true; // handled - don't also draw the generic placeholder below + } } if (!drawn) drawZonePlaceholder(ctx, dx, dy, dw, dh, zonePlaceholderLabel(el)); }); @@ -3469,6 +3516,10 @@ const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; let captured = false; + // Set when a video IS on screen and playing but its pixels are unreachable (hardware plane). + // Deliberately distinct from "no content": the operator needs to know the panel is healthy + // and it is the CAPTURE that is limited. + let videoUncapturable = false; try { const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig); @@ -3480,7 +3531,13 @@ const video = container.querySelector('video'); const img = container.querySelector('img'); if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) { - try { drawMediaFit(ctx, video, video.videoWidth, video.videoHeight, 0, 0, W, H, getComputedStyle(video).objectFit); captured = true; } catch (e) { console.warn('Video capture failed (CORS?):', e.message); } + if (videoFrameIsCapturable(video)) { + try { drawMediaFit(ctx, video, video.videoWidth, video.videoHeight, 0, 0, W, H, getComputedStyle(video).objectFit); captured = true; } catch (e) { console.warn('Video capture failed (CORS?):', e.message); } + } else { + // Playing fine, simply not readable from the DOM. Fall through to the status card + // and SAY so, rather than emitting a black frame that reads as a dead screen. + videoUncapturable = true; + } } if (!captured && img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) { try { drawMediaFit(ctx, img, img.naturalWidth, img.naturalHeight, 0, 0, W, H, getComputedStyle(img).objectFit); captured = true; } catch (e) { console.warn('Image capture failed:', e.message); } @@ -3500,6 +3557,11 @@ const item = playlist[currentIndex]; ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', W / 2, H / 2); ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, W / 2, H / 2 + 40); + if (videoUncapturable) { + // A limitation of the CAPTURE must never read as a fault on the SCREEN. + ctx.fillStyle = '#fbbf24'; + ctx.fillText('Video is playing on the hardware plane and cannot be captured', W / 2, H / 2 + 72); + } } } catch (e) { // Even on error, draw something diff --git a/server/test/player-capture-hardware-plane.test.js b/server/test/player-capture-hardware-plane.test.js new file mode 100644 index 0000000..1bbb969 --- /dev/null +++ b/server/test/player-capture-hardware-plane.test.js @@ -0,0 +1,152 @@ +'use strict'; + +// A screenshot that comes back BLANK while the panel is playing perfectly is worse than no +// screenshot at all: it reads as a dead screen and sends someone to site. +// +// That is what a BrightSign did. With hwz enabled the video decodes onto a HARDWARE PLANE outside +// the browser compositor — BrightSign's own documentation says the HTML/JS layer "doesn't see the +// pixels" — so `drawImage(video)` produces a fully TRANSPARENT image and throws nothing. Chromium +// 87 (which this XT245 reports) fails the same way. The capture path set `captured = true` purely +// because drawMediaFit() had not thrown, so the player emitted an empty frame and logged +// "Screenshot sent". Success reported, nothing done. +// +// isMediaReadable() does not catch this: it answers "am I ALLOWED to read this" (same-origin/CORS), +// which is a different question from "did any pixels arrive". +// +// The discriminator is ALPHA, not colour. A scratch canvas starts fully transparent and a real +// decoded frame writes alpha=255 even when the frame is pure black — so a legitimate fade-to-black +// must still read as captured, while "nothing arrived" must not. Both directions are pinned below. +// +// Extracted and run against fake canvas/video objects, in the same style as the other tests that +// exercise player functions without a browser. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); + +/** Pull one top-level function out of the player and return it, brace-matched. */ +function extract(name) { + const start = HTML.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} not found in index.html`); + let depth = 0, end = -1; + for (let j = HTML.indexOf('{', start); j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; } + } + assert.notEqual(end, -1, `${name} braces unbalanced`); + return HTML.slice(start, end); +} + +/** + * Build videoFrameIsCapturable with a fake document. + * `alpha` is what getImageData reports for every pixel; `throws` selects a failure mode. + */ +function build({ alpha = 255, drawThrows = false, getImageDataThrows = false, noCtx = false } = {}) { + const src = extract('videoFrameIsCapturable'); + const calls = { draws: 0, sizes: [] }; + const scope = { + document: { + createElement: () => ({ + width: 0, height: 0, + getContext: () => (noCtx ? null : { + drawImage: (el, x, y, w, h) => { + if (drawThrows) throw new Error('InvalidStateError'); + calls.draws++; calls.sizes.push([w, h]); + }, + getImageData: (x, y, w, h) => { + if (getImageDataThrows) { + const e = new Error('The canvas has been tainted'); e.name = 'SecurityError'; throw e; + } + const data = new Uint8ClampedArray(w * h * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = alpha; + } + return { data }; + }, + }), + }), + }, + Uint8ClampedArray, + }; + const fn = new Function(...Object.keys(scope), `${src} return videoFrameIsCapturable;`)(...Object.values(scope)); + return { fn, calls }; +} + +const fakeVideo = { videoWidth: 1920, videoHeight: 1080, readyState: 4 }; + +test('THE BUG: a transparent result means nothing was drawn, not a black frame', () => { + // This is the BrightSign hwz case: the draw "succeeds", the canvas stays untouched. + const { fn } = build({ alpha: 0 }); + assert.equal(fn(fakeVideo), false, 'a fully transparent probe must not count as captured'); +}); + +test('a genuinely BLACK frame still counts as captured — colour is not the test', () => { + // RGB is 0,0,0 here and alpha is 255. A fade-to-black or a letterboxed frame must not be + // mistaken for a failed capture, or the screenshot would be replaced by a status card at + // exactly the moment a video dips to black. + const { fn } = build({ alpha: 255 }); + assert.equal(fn(fakeVideo), true); +}); + +test('a partially opaque frame counts as captured', () => { + const { fn } = build({ alpha: 1 }); + assert.equal(fn(fakeVideo), true, 'any non-zero alpha is evidence pixels arrived'); +}); + +test('a tainted canvas counts as captured — tainting PROVES pixels were drawn', () => { + // getImageData throwing SecurityError only happens once cross-origin content has been drawn, + // so this failure is evidence of success. Treating it as failure would break screenshots for + // every legitimately cross-origin video. + const { fn } = build({ getImageDataThrows: true }); + assert.equal(fn(fakeVideo), true); +}); + +test('a throwing drawImage counts as NOT captured', () => { + // Distinct from the tainted case above: here the draw itself failed, so nothing landed. + const { fn } = build({ drawThrows: true }); + assert.equal(fn(fakeVideo), false); +}); + +test('no 2d context available is not captured, and does not throw', () => { + const { fn } = build({ noCtx: true }); + assert.doesNotThrow(() => fn(fakeVideo)); + assert.equal(fn(fakeVideo), false); +}); + +test('a missing video is not captured', () => { + const { fn } = build(); + assert.equal(fn(null), false); + assert.equal(fn(undefined), false); +}); + +test('the probe is small — this runs once per frame on a 1fps stream', () => { + // A full-size draw purely to test drawability would double the cost of every streamed frame. + const { fn, calls } = build({ alpha: 255 }); + fn(fakeVideo); + assert.deepEqual(calls.sizes[0], [16, 16]); +}); + +// ---------------------------------------------------------------- wiring, not just the helper + +test('both capture paths probe BEFORE drawing, so the placeholder can still be drawn', () => { + // Probing after the draw would waste a full-size drawImage on every frame, and in the zone path + // it would leave a black rectangle already painted underneath the placeholder. + const zone = HTML.slice(HTML.indexOf('function drawZoneComposite'), HTML.indexOf('function renderCaptureCanvas')); + assert.match(zone, /if \(videoFrameIsCapturable\(el\)\) \{/, 'zone path must gate the draw on the probe'); + assert.match(zone, /Video \(not capturable\)/, 'a video zone that cannot be read must be LABELLED, not left black'); + + const full = HTML.slice(HTML.indexOf('function renderCaptureCanvas'), HTML.indexOf('function captureAndSend')); + assert.match(full, /if \(videoFrameIsCapturable\(video\)\) \{/, 'fullscreen path must gate the draw on the probe'); + assert.match(full, /videoUncapturable = true/, 'fullscreen path must record WHY it fell through'); +}); + +test('the operator is told the panel is fine and the capture is what is limited', () => { + // The status card is also what shows for "no content". Without this line an operator seeing it + // would reasonably conclude the screen was blank when the video was playing normally. + const full = HTML.slice(HTML.indexOf('function renderCaptureCanvas'), HTML.indexOf('function captureAndSend')); + assert.match(full, /if \(videoUncapturable\) \{/); + assert.match(full, /hardware plane and cannot be captured/i); +});