mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Merge pull request #249 from screentinker/fix/brightsign-umd-and-transition-guard
Export shared modules to the browser even when Node is in the page
This commit is contained in:
commit
04f88d3340
|
|
@ -13,8 +13,11 @@
|
||||||
//
|
//
|
||||||
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
|
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
// BOTH, not either/or — see schedule-eval.js. Node integration in a BrightSign widget made the
|
||||||
else root.PlayerMediaHealth = factory();
|
// browser branch unreachable, so the player ran without its media-health decision there.
|
||||||
|
var api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.PlayerMediaHealth = api;
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,13 @@
|
||||||
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
|
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
|
||||||
|
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
// BOTH, not either/or: a BrightSign widget runs with Node integration, so `module` exists in
|
||||||
else root.ScheduleEval = factory();
|
// page scope and an `else` left root.ScheduleEval undefined there. The player falls back to
|
||||||
|
// "always active" when it is missing — i.e. per-item DAYPARTING silently stopped applying on
|
||||||
|
// that platform, and scheduled content played outside its window with nothing in any log.
|
||||||
|
var api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.ScheduleEval = api;
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3152,12 +3152,36 @@
|
||||||
// the frame on screen now, as a texturable (CORS-clean) source: the live <img>, or — so a wipe can
|
// the frame on screen now, as a texturable (CORS-clean) source: the live <img>, or — so a wipe can
|
||||||
// start FROM a playing clip — a snapshot canvas of the outgoing <video>'s current frame. Returns null
|
// start FROM a playing clip — a snapshot canvas of the outgoing <video>'s current frame. Returns null
|
||||||
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
|
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
|
||||||
|
// Does a <video> on THIS platform actually yield pixels to a canvas?
|
||||||
|
//
|
||||||
|
// On a hardware video plane (BrightSign hwz, Tizen AVPlay) the answer is no, and the failure is
|
||||||
|
// silent: drawImage() succeeds, throws nothing, and paints a fully TRANSPARENT frame. The
|
||||||
|
// transition then runs with a blank `from` or `to` texture — a wipe from nothing, behind a video
|
||||||
|
// plane that is still lit. isMediaReadable() cannot catch it: it answers "am I ALLOWED to read
|
||||||
|
// this" (CORS), which is a different question from "did any pixels arrive".
|
||||||
|
//
|
||||||
|
// videoFrameIsCapturable() already asks the right question (a 16x16 ALPHA probe, so a genuine
|
||||||
|
// fade-to-black still reads as captured) but was only ever wired into the screenshot path.
|
||||||
|
// Cached because the answer is a property of the platform, not of the clip.
|
||||||
|
let _videoCompositingOk = null;
|
||||||
|
function videoCompositingAvailable(v) {
|
||||||
|
if (_videoCompositingOk !== null) return _videoCompositingOk;
|
||||||
|
if (!v || v.readyState < 2 || !v.videoWidth) return true; // undecided — don't cache a guess
|
||||||
|
_videoCompositingOk = videoFrameIsCapturable(v);
|
||||||
|
if (!_videoCompositingOk) {
|
||||||
|
console.log('[transition] video frames are not readable on this platform (hardware plane) — ' +
|
||||||
|
'transitions involving video will hard-cut; image-to-image still wipes');
|
||||||
|
}
|
||||||
|
return _videoCompositingOk;
|
||||||
|
}
|
||||||
|
|
||||||
function currentTexturableFrame() {
|
function currentTexturableFrame() {
|
||||||
const c = document.getElementById('playerContainer');
|
const c = document.getElementById('playerContainer');
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const img = c.querySelector('img');
|
const img = c.querySelector('img');
|
||||||
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
|
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
|
||||||
const v = c.querySelector('video');
|
const v = c.querySelector('video');
|
||||||
|
if (v && !videoCompositingAvailable(v)) return null; // hardware plane -> no from-frame -> hard cut
|
||||||
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
|
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
|
||||||
try {
|
try {
|
||||||
const r = c.getBoundingClientRect();
|
const r = c.getBoundingClientRect();
|
||||||
|
|
@ -3456,7 +3480,12 @@
|
||||||
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
|
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
|
||||||
&& !item.widget_id && !wallConfig && !isZones && !groupSync
|
&& !item.widget_id && !wallConfig && !isZones && !groupSync
|
||||||
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
|
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
|
||||||
&& transitionRuntimeReady();
|
&& transitionRuntimeReady()
|
||||||
|
// A platform whose video sits on a hardware plane cannot supply the incoming frame either:
|
||||||
|
// the warm-play snapshot comes back transparent, so the wipe would fade in from nothing.
|
||||||
|
// `null` means "not yet determined" and is treated as available — the probe needs a
|
||||||
|
// playing video, and the first one on a fresh player has not run yet.
|
||||||
|
&& _videoCompositingOk !== false;
|
||||||
if (isVideoBufferable) {
|
if (isVideoBufferable) {
|
||||||
renderVideoBuffered(item);
|
renderVideoBuffered(item);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
142
server/test/player-transition-hardware-plane.test.js
Normal file
142
server/test/player-transition-hardware-plane.test.js
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
|
@ -69,8 +69,16 @@ const VERTEX = `attribute vec2 aPos;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
|
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
|
||||||
|
|
||||||
|
// Export to BOTH, never either/or. A BrightSign roHtmlWidget is created with
|
||||||
|
// `nodejs_enabled: true`, which puts Node's `module` into the page's classic-script scope — so an
|
||||||
|
// `else` here means the browser branch never runs on that platform and `self.TransitionParams` is
|
||||||
|
// simply never set. The player's transitionRuntimeReady() is a presence check on exactly that
|
||||||
|
// global, so transitions were dark on every BrightSign, and the failure looked like a GPU problem
|
||||||
|
// rather than a module-format one. Anything a browser is meant to see must be assigned
|
||||||
|
// unconditionally.
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX };
|
module.exports = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX };
|
||||||
} else if (typeof self !== 'undefined') {
|
}
|
||||||
|
if (typeof self !== 'undefined') {
|
||||||
self.TransitionParams = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX }; // browser (player/demo)
|
self.TransitionParams = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX }; // browser (player/demo)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@
|
||||||
//
|
//
|
||||||
// Never-blank teeth live here too: on `webglcontextlost` the renderer flips `lost` and calls
|
// Never-blank teeth live here too: on `webglcontextlost` the renderer flips `lost` and calls
|
||||||
// opts.onContextLost so the player can hard-cut to a plain <img> instead of showing black.
|
// opts.onContextLost so the player can hard-cut to a plain <img> instead of showing black.
|
||||||
|
// Exports to BOTH targets — see the note in params.js. On BrightSign (`nodejs_enabled: true`)
|
||||||
|
// `module` exists in page scope, so an `else` leaves root.TransitionRenderer undefined and the
|
||||||
|
// whole transition runtime silently absent.
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
|
var api = factory();
|
||||||
else root.TransitionRenderer = factory();
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
if (root) root.TransitionRenderer = api;
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,13 @@
|
||||||
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
|
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
|
||||||
|
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
// BOTH, not either/or: a BrightSign widget runs with Node integration, so `module` exists in
|
||||||
else root.ScheduleEval = factory();
|
// page scope and an `else` left root.ScheduleEval undefined there. The player falls back to
|
||||||
|
// "always active" when it is missing — i.e. per-item DAYPARTING silently stopped applying on
|
||||||
|
// that platform, and scheduled content played outside its window with nothing in any log.
|
||||||
|
var api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.ScheduleEval = api;
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,11 @@ const VERTEX = `attribute vec2 aPos;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
|
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
|
||||||
|
|
||||||
|
// Hand-committed copy of shared/Transitions — keep the both-targets export in step with it.
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX };
|
module.exports = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX };
|
||||||
} else if (typeof self !== 'undefined') {
|
}
|
||||||
|
if (typeof self !== 'undefined') {
|
||||||
self.TransitionParams = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX }; // browser (player/demo)
|
self.TransitionParams = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX }; // browser (player/demo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,8 +89,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
// Never-blank teeth live here too: on `webglcontextlost` the renderer flips `lost` and calls
|
// Never-blank teeth live here too: on `webglcontextlost` the renderer flips `lost` and calls
|
||||||
// opts.onContextLost so the player can hard-cut to a plain <img> instead of showing black.
|
// opts.onContextLost so the player can hard-cut to a plain <img> instead of showing black.
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
|
var api = factory();
|
||||||
else root.TransitionRenderer = factory();
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
if (root) root.TransitionRenderer = api;
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue