screentinker/server/test/player-transition-hardware-plane.test.js
ScreenTinker 95b8d1b293 Export shared modules to the browser even when Node is in the page
Transitions have never run on BrightSign, and it was never a GPU problem.

`transitionRuntimeReady()` is a presence check on three globals and touches no
WebGL at all. A BrightSign roHtmlWidget is created with `nodejs_enabled: true`,
which puts Node's `module` into classic-script scope — so every shared module
that exported with an `else` took the CommonJS branch and never assigned its
browser global. The runtime was absent before WebGL was ever asked a question.

This is deducible from the fleet without touching the hardware: the player
pushes system.reboot / display.power / display.resolution / system.self_update
only behind BS.hasHost(), which needs require('@brightsign/messageport') to
resolve. Our XT245's stored capability row carries all four, so Node
integration was live in that page, so the CommonJS branch was taken.

Transitions are the least of it. schedule-eval.js had the same shape, and the
player falls back to "always active" when ScheduleEval is missing — so per-item
DAYPARTING silently stopped applying on that platform and scheduled content
played outside its window with nothing in any log. player-media-health.js the
same. Four files, all fixed by exporting to BOTH targets rather than either/or.

media-mute.js, orientation-style.js and wall-geometry.js already assigned their
globals in a separate unconditional block and were never affected; the audit
that reached me claimed all seven, and reading them is what separated the four
from the three.

THE GUARD, WITHOUT WHICH THE ABOVE IS A REGRESSION.

Restore the globals alone and BrightSign starts attempting video wipes it
cannot supply. On a hardware video plane drawImage(video) succeeds, throws
nothing, and paints a fully TRANSPARENT frame — so the wipe fades from nothing,
behind a video plane that is still lit. Worse than the hard cut it replaces.

The discriminator already existed: videoFrameIsCapturable() probes ALPHA, so a
genuine fade-to-black still reads as captured. It was wired into the screenshot
path and not this one, which asked isMediaReadable() — a CORS question, "am I
allowed to read this", not "did any pixels arrive". Both the outgoing frame and
the incoming warm-play snapshot now consult it, cached per platform, defaulting
to available while undetermined so a cold start is not crippled.

Net effect on BrightSign: image-to-image transitions light up, anything
involving video hard-cuts honestly, and dayparting starts working.

Full video transitions are reachable later — BrightSign documents that video
"captured as a canvas for WebGL processing must be routed to the GPU" via a
per-element hwz="off", which keeps hardware decode at an 8-bit/1080p ceiling.
That needs the hardware to validate and is not in this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 10:36:50 -05:00

143 lines
7.1 KiB
JavaScript

'use strict';
/*
* Two bugs meet in this file, and the second only becomes reachable once the first is fixed.
*
* 1. The transition runtime never loaded on BrightSign. `transitionRuntimeReady()` is a presence
* check on three globals and touches no WebGL at all — so "transitions don't work on BrightSign"
* was never a GPU story. A BrightSign roHtmlWidget runs with `nodejs_enabled: true`, which puts
* Node's `module` into classic-script scope, so every UMD module that exported with an `else`
* took the CommonJS branch and never assigned its browser global.
*
* 2. Once the runtime DOES load there, video transitions would run against blank textures. On a
* hardware video plane `drawImage(video)` succeeds, throws nothing, and paints a fully
* TRANSPARENT frame — so the wipe fades from nothing, behind a video plane that is still lit.
* Fixing (1) without (2) is therefore a REGRESSION: visibly worse than today's hard cut.
*
* The guard already existed for screenshots (`videoFrameIsCapturable`, an ALPHA probe) and simply
* was not wired into the transition path, which asked `isMediaReadable()` — a CORS question, i.e.
* "am I allowed to read this", not "did any pixels arrive".
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const PLAYER = read('server/player/index.html');
// ---------------------------------------------------------------- UMD export shape
// Every module a browser is meant to see must assign its global UNCONDITIONALLY. An `else` against
// a `module`/`module.exports` test is the bug: it is invisible everywhere except a page with Node
// integration, where it silently removes the global and every consumer falls back.
const BROWSER_SHARED_MODULES = [
['shared/Transitions/params.js', 'TransitionParams'],
['shared/Transitions/renderer.js', 'TransitionRenderer'],
['server/lib/schedule-eval.js', 'ScheduleEval'],
['server/lib/player-media-health.js', 'PlayerMediaHealth'],
['server/lib/media-mute.js', 'MediaMute'],
['server/lib/orientation-style.js', 'OrientationStyle'],
['server/lib/wall-geometry.js', 'WallGeometry'],
['tizen/js/transitions.js', 'TransitionParams'],
];
for (const [file, globalName] of BROWSER_SHARED_MODULES) {
test(`#BS-UMD: ${file} exports ${globalName} without an else`, () => {
const src = read(file);
// The exact hazard: a CommonJS test whose ELSE branch is the only path to the browser global.
const elseHazard = /module\.exports[^\n]*\n?\s*(\}\s*)?else\b/.test(src)
|| /if\s*\(\s*typeof module[^)]*\)\s*module\.exports[^\n]*\n\s*else\b/.test(src);
assert.equal(elseHazard, false,
`${file} exports its browser global from an else branch — invisible except under Node ` +
`integration (BrightSign), where the global silently never appears`);
assert.ok(
new RegExp(`(self|root|window)\\.${globalName}\\s*=`).test(src),
`${file} must assign ${globalName} for browsers`,
);
});
}
test('#BS-UMD: the runtime check is a plain global presence test, so a missing global IS the outage', () => {
// Documents WHY the export shape matters this much: nothing here touches WebGL, so a module-format
// problem and a GPU problem are indistinguishable from the outside.
const fn = PLAYER.slice(PLAYER.indexOf('function transitionRuntimeReady()'));
const body = fn.slice(0, fn.indexOf('}') + 1);
assert.match(body, /window\.TransitionRenderer/);
assert.match(body, /window\.TransitionParams/);
assert.match(body, /window\.__TRANSITION_SHADERS/);
assert.ok(!/getContext|webgl/i.test(body), 'this check must not be mistaken for a WebGL probe');
});
// ---------------------------------------------------------------- hardware-plane guard
// Extract a function body out of index.html by brace matching — the established pattern in this
// suite (see player-capture-hardware-plane.test.js).
function extract(name) {
const at = PLAYER.indexOf(`function ${name}(`);
assert.ok(at > 0, `${name} not found`);
let i = PLAYER.indexOf('{', at), depth = 0, end = i;
for (; end < PLAYER.length; end++) {
if (PLAYER[end] === '{') depth++;
else if (PLAYER[end] === '}' && --depth === 0) { end++; break; }
}
return PLAYER.slice(at, end);
}
// A canvas whose pixels we control, so "video produced nothing" is expressible.
function fakeCanvas(alpha) {
return {
width: 0, height: 0,
getContext: () => ({
drawImage() { /* succeeds and paints nothing, exactly like a hardware plane */ },
getImageData: (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4).fill(0).map((_, i) => (i % 4 === 3 ? alpha : 0)) }),
}),
};
}
function runCapturable(alpha) {
const src = extract('videoFrameIsCapturable');
const fn = new Function('document', `${src}; return videoFrameIsCapturable;`)({
createElement: () => fakeCanvas(alpha),
});
return fn({ readyState: 4, videoWidth: 1920 });
}
test('#BS-guard: a transparent frame means the pixels never arrived', () => {
assert.equal(runCapturable(0), false, 'alpha 0 across the probe is a hardware plane, not a video');
});
test('#BS-guard: a genuine fade-to-black is still a captured frame', () => {
// The biconditional that makes the probe honest: black pixels are opaque, so a legitimately dark
// frame must NOT be mistaken for "nothing arrived" — otherwise every fade would hard-cut.
assert.equal(runCapturable(255), true, 'opaque black is a real frame');
});
test('#BS-guard: the transition path consults capturability, not just CORS', () => {
const fn = extract('currentTexturableFrame');
assert.match(fn, /videoCompositingAvailable/,
'the outgoing frame must be gated on whether pixels actually arrive');
// isMediaReadable answers a different question and must not be the only gate on the video branch.
const videoBranch = fn.slice(fn.indexOf("querySelector('video')"));
assert.ok(videoBranch.indexOf('videoCompositingAvailable') < videoBranch.indexOf('isMediaReadable'),
'the capturability guard must run BEFORE the CORS check short-circuits the branch');
});
test('#BS-guard: an incoming video is not buffered for a wipe it cannot supply', () => {
const at = PLAYER.indexOf('const isVideoBufferable');
assert.ok(at > 0);
const decl = PLAYER.slice(at, PLAYER.indexOf(';', at));
assert.match(decl, /_videoCompositingOk !== false/,
'image→video would otherwise fade in from a transparent texture');
assert.match(decl, /transitionRuntimeReady\(\)/, 'the runtime is still required');
});
test('#BS-guard: undetermined is treated as available, so a fresh player is not crippled', () => {
// The probe needs a playing video; the first item on a cold start has not run one. Defaulting to
// "unavailable" would disable video transitions everywhere until a video happened to play.
const fn = extract('videoCompositingAvailable');
assert.match(fn, /return true/, 'an undecided probe must not latch off');
assert.match(fn, /_videoCompositingOk !== null/, 'the platform answer must be cached once known');
});