mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Screenshots: prove pixels arrived instead of assuming the draw worked
A BrightSign emitted BLANK screenshots and logged "Screenshot sent". With hwz
enabled the video decodes onto a hardware plane outside the browser compositor
— BrightSign's docs say 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.
Both capture paths set captured/drawn = true purely because drawMediaFit() had
not thrown. So the dashboard showed a dead screen while the panel played
perfectly, and the zone path painted a black rectangle in place of the labelled
placeholder drawZonePlaceholder() exists to guarantee ("never a transparent
hole"). 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".
videoFrameIsCapturable() probes a 16x16 scratch canvas before committing to a
full-size draw. ALPHA is the discriminator, not colour: a scratch canvas starts
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" does not. A tainted canvas counts as captured, because
tainting only happens once cross-origin pixels have actually been drawn.
Probing BEFORE the draw matters twice: it avoids a wasted full-size drawImage on
every frame of a 1fps stream, and in the zone path it stops a black rectangle
being painted underneath the placeholder.
When a video is on screen but unreadable the status card now says so, because
that card is also what shows for "no content" — without the line an operator
would reasonably conclude the screen was blank.
Not gated on BrightSign: the same silent failure exists for any stalled decoder
or engine that declines to hand back frames.
10 tests, 964 pass.
This commit is contained in:
parent
141deb97a5
commit
e606cc83d1
|
|
@ -3307,6 +3307,45 @@
|
||||||
catch (e) { return false; }
|
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) {
|
function zonePlaceholderLabel(el) {
|
||||||
if (!el) return 'Live';
|
if (!el) return 'Live';
|
||||||
if (el.tagName === 'IFRAME') {
|
if (el.tagName === 'IFRAME') {
|
||||||
|
|
@ -3360,7 +3399,15 @@
|
||||||
if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0 && isMediaReadable(el)) {
|
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) {}
|
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)) {
|
} else if (el && el.tagName === 'VIDEO' && el.readyState >= 2 && el.videoWidth > 0 && isMediaReadable(el)) {
|
||||||
|
// 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) {}
|
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));
|
if (!drawn) drawZonePlaceholder(ctx, dx, dy, dw, dh, zonePlaceholderLabel(el));
|
||||||
});
|
});
|
||||||
|
|
@ -3381,6 +3428,10 @@
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const W = canvas.width, H = canvas.height;
|
const W = canvas.width, H = canvas.height;
|
||||||
let captured = false;
|
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 {
|
try {
|
||||||
const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig);
|
const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig);
|
||||||
|
|
@ -3392,7 +3443,13 @@
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector('video');
|
||||||
const img = container.querySelector('img');
|
const img = container.querySelector('img');
|
||||||
if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) {
|
if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) {
|
||||||
|
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); }
|
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)) {
|
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); }
|
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); }
|
||||||
|
|
@ -3412,6 +3469,11 @@
|
||||||
const item = playlist[currentIndex];
|
const item = playlist[currentIndex];
|
||||||
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', W / 2, H / 2);
|
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);
|
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) {
|
} catch (e) {
|
||||||
// Even on error, draw something
|
// Even on error, draw something
|
||||||
|
|
|
||||||
152
server/test/player-capture-hardware-plane.test.js
Normal file
152
server/test/player-capture-hardware-plane.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue