mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
138 lines
6.1 KiB
JavaScript
138 lines
6.1 KiB
JavaScript
// Portable WebGL transition compositor (GL Transitions v1).
|
|
//
|
|
// ONE implementation, consumed by both the web player (inlined at build) and the Tizen player.
|
|
// No DOM dependency beyond the <canvas> handed in. The .glsl shaders + params.js are the single
|
|
// source of truth; this module only wraps (PREAMBLE + shader + EPILOGUE, shared VERTEX) and runs
|
|
// them. It composites TWO textures (from -> to) across `progress` 0..1; the outgoing frame stays
|
|
// live in `uFrom` for the whole transition, so there is never a blank seam.
|
|
//
|
|
// 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.
|
|
// 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) {
|
|
var api = factory();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
if (root) root.TransitionRenderer = api;
|
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
'use strict';
|
|
|
|
function compile(gl, type, src) {
|
|
const s = gl.createShader(type);
|
|
gl.shaderSource(s, src);
|
|
gl.compileShader(s);
|
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
const log = gl.getShaderInfoLog(s);
|
|
gl.deleteShader(s);
|
|
throw new Error('shader compile failed: ' + log);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// wrap = { PREAMBLE, EPILOGUE, VERTEX } from params.js
|
|
function createRenderer(canvas, wrap, opts) {
|
|
opts = opts || {};
|
|
const attrs = {
|
|
alpha: false, antialias: false, depth: false, stencil: false,
|
|
premultipliedAlpha: false, preserveDrawingBuffer: !!opts.preserveDrawingBuffer,
|
|
};
|
|
const gl = canvas.getContext('webgl', attrs) || canvas.getContext('experimental-webgl', attrs);
|
|
if (!gl) throw new Error('no-webgl');
|
|
|
|
let lost = false;
|
|
const onLost = (e) => { e.preventDefault(); lost = true; if (opts.onContextLost) opts.onContextLost(); };
|
|
const onRestored = () => { lost = false; programs = {}; buildQuad(); if (opts.onContextRestored) opts.onContextRestored(); };
|
|
canvas.addEventListener('webglcontextlost', onLost, false);
|
|
canvas.addEventListener('webglcontextrestored', onRestored, false);
|
|
|
|
let quadBuf, vShader;
|
|
function buildQuad() {
|
|
quadBuf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
vShader = compile(gl, gl.VERTEX_SHADER, wrap.VERTEX);
|
|
}
|
|
buildQuad();
|
|
|
|
let programs = {}; // shaderSrc -> { program, uni:{} }
|
|
function programFor(src) {
|
|
if (programs[src]) return programs[src];
|
|
const f = compile(gl, gl.FRAGMENT_SHADER, wrap.PREAMBLE + '\n' + src + '\n' + wrap.EPILOGUE);
|
|
const p = gl.createProgram();
|
|
gl.attachShader(p, vShader);
|
|
gl.attachShader(p, f);
|
|
gl.bindAttribLocation(p, 0, 'aPos');
|
|
gl.linkProgram(p);
|
|
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
const log = gl.getProgramInfoLog(p);
|
|
gl.deleteProgram(p); gl.deleteShader(f);
|
|
throw new Error('program link failed: ' + log);
|
|
}
|
|
gl.deleteShader(f);
|
|
programs[src] = { program: p, uni: {} };
|
|
return programs[src];
|
|
}
|
|
function uniLoc(rec, name) {
|
|
if (!(name in rec.uni)) rec.uni[name] = gl.getUniformLocation(rec.program, name);
|
|
return rec.uni[name];
|
|
}
|
|
|
|
function makeTex() {
|
|
const t = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
return t;
|
|
}
|
|
let texFrom = makeTex(), texTo = makeTex();
|
|
let curShader = null;
|
|
|
|
function upload(tex, source) {
|
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // match getFromColor/getToColor uv convention
|
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
}
|
|
|
|
return {
|
|
get lost() { return lost; },
|
|
gl,
|
|
// Compile eagerly so a bad shader throws HERE (caller hard-cuts) rather than mid-transition.
|
|
setShader(src) { curShader = src; programFor(src); },
|
|
setFrom(img) { upload(texFrom, img); },
|
|
setTo(img) { upload(texTo, img); },
|
|
resize(w, h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); },
|
|
render(progress, params) {
|
|
if (lost || !curShader) return false;
|
|
const rec = programFor(curShader);
|
|
gl.useProgram(rec.program);
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texFrom); gl.uniform1i(uniLoc(rec, 'uFrom'), 0);
|
|
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texTo); gl.uniform1i(uniLoc(rec, 'uTo'), 1);
|
|
gl.uniform1f(uniLoc(rec, 'progress'), Math.max(0, Math.min(1, progress)));
|
|
gl.uniform1f(uniLoc(rec, 'ratio'), canvas.width / Math.max(1, canvas.height));
|
|
if (params) for (const k in params) { const loc = uniLoc(rec, k); if (loc) gl.uniform1f(loc, params[k]); }
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
|
gl.enableVertexAttribArray(0);
|
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
return true;
|
|
},
|
|
destroy() {
|
|
canvas.removeEventListener('webglcontextlost', onLost);
|
|
canvas.removeEventListener('webglcontextrestored', onRestored);
|
|
try {
|
|
gl.deleteTexture(texFrom); gl.deleteTexture(texTo); gl.deleteBuffer(quadBuf);
|
|
for (const k in programs) gl.deleteProgram(programs[k].program);
|
|
} catch (e) { /* context already gone */ }
|
|
programs = {};
|
|
},
|
|
};
|
|
}
|
|
|
|
return { createRenderer, compile };
|
|
});
|