fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)

Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
This commit is contained in:
screentinker 2026-07-20 16:58:28 -05:00 committed by GitHub
parent 96b71a0d56
commit ba00dd2811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 53 additions and 17 deletions

View file

@ -99,15 +99,12 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
visibility = GONE
}
/** Main-thread entry: run a wipe. If the runtime can't start it, onDone still fires (hard cut). */
/** Main-thread entry: run a wipe. If the runtime can't start it, onDone still fires (hard cut).
* onDone is the RAW content swap the overlay's visibility/render-mode are owned by the renderer
* (parkIfIdle), NOT by this callback, so a superseded wipe's late onDone can't hide the overlay out
* from under a newer wipe that's already in flight (that was a playlist-wedge bug). */
fun play(from: Bitmap, to: Bitmap, fragmentSrc: String, params: Map<String, Float>, durationMs: Int, onDone: () -> Unit) {
val job = Job(from, to, fragmentSrc, params, durationMs.coerceAtLeast(1)) {
// wrap so the view is hidden + parked on the main thread right when the swap happens
visibility = GONE
renderMode = RENDERMODE_WHEN_DIRTY
onDone()
}
incoming = job
incoming = Job(from, to, fragmentSrc, params, durationMs.coerceAtLeast(1), onDone)
visibility = VISIBLE
renderMode = RENDERMODE_CONTINUOUSLY
requestRender()
@ -129,16 +126,17 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 0f) // transparent: content behind shows through pre-wipe
vShader = compile(GLES20.GL_VERTEX_SHADER, TransitionGlsl.VERTEX)
// a context (re)create drops any active job's GL objects — abandon it, the caller already
// swapped or will on the next advance; never leave the overlay stuck visible.
active?.let { finishOnMain(it) }
// a context (re)create drops any active job's GL objects — abandon it (hard-cut to its target),
// and park if nothing new is queued so the overlay never sticks visible.
active?.let { swapOnMain(it) }
active = null; program = 0; texFrom = 0; texTo = 0
parkIfIdle()
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) { vw = width; vh = height; GLES20.glViewport(0, 0, width, height) }
override fun onDrawFrame(gl: GL10?) {
incoming?.let { j -> incoming = null; active?.let { finishOnMain(it) }; setup(j) } // pick up a new request
incoming?.let { j -> incoming = null; active?.let { supersede(it) }; setup(j) } // pick up a new request
val j = active
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
if (j == null) return
@ -172,6 +170,9 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
for (name in j.params.keys) uParam[name] = GLES20.glGetUniformLocation(prog, name)
texFrom = uploadTexture(j.from)
texTo = uploadTexture(j.to)
// pixels now live in the GL textures — free the (full-screen ARGB) fit bitmaps promptly
// rather than waiting on GC, which matters on a long-lived signage device.
try { j.from.recycle(); j.to.recycle() } catch (_: Throwable) {}
} catch (e: Throwable) {
Log.w("TransitionGL", "wipe setup failed, hard-cutting: ${e.message}")
j.failed = true
@ -193,14 +194,30 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
GLES20.glDisableVertexAttribArray(0)
}
// Release GL objects, then run the job's onDone on the main thread (the content swap happens there).
// Wipe completed (or failed): release its GL, run the content swap on the main thread, then park
// the overlay IF no newer wipe is queued.
private fun finish(j: Job) {
active = null
releaseGl()
finishOnMain(j)
swapOnMain(j)
parkIfIdle()
}
private fun finishOnMain(j: Job) { post { j.onDone() } } // View.post -> main thread; guarded once by active handoff
// Superseded by a newer wipe (advance fired mid-wipe): release THIS wipe's GL (else its program +
// both textures leak) and run its content swap, but do NOT park — the new wipe keeps the overlay
// visible + rendering. setup(new) runs immediately after and rebuilds the GL objects.
private fun supersede(old: Job) {
releaseGl()
swapOnMain(old)
}
private fun swapOnMain(j: Job) { post { j.onDone() } } // View.post -> main thread (the content swap)
// Hide the overlay + stop the render loop, but ONLY if nothing new is queued. `incoming` is
// @Volatile and mutated only on the main thread (play), the same thread this posted block runs on,
// so a newer play() either already set incoming (we skip park) or runs after (it re-shows) —
// race-free, and never leaves a new wipe hidden.
private fun parkIfIdle() { post { if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY } } }
private fun releaseGl() {
if (texFrom != 0) { GLES20.glDeleteTextures(1, intArrayOf(texFrom), 0); texFrom = 0 }

View file

@ -2381,10 +2381,17 @@
// frame as the wipe's `to`, run the GL wipe, then mount + resume the real <video> FROM that same
// frame (zero jump). Every failure path (no from-frame, un-decodable, no runtime, context loss, or
// the decode watchdog) hard-cuts straight to mount+play — never a blank.
// Bumped on every renderContent dispatch. A buffered render captures the value at start and bails if
// it changes — the warm-play is async (first-frame wait + the wipe), and a playlist push mid-window
// must not let a stale clip tear down the newer content that already took over.
let renderSeq = 0;
function renderVideoBuffered(item) {
const src = item.remote_url || `${config.serverUrl}/uploads/content/${item.filepath}`;
const from = currentTexturableFrame(); // capture the outgoing frame NOW, before any teardown
const t = item.transition;
const mySeq = renderSeq;
const stale = () => renderSeq !== mySeq; // a newer renderContent superseded this one
const abandon = () => { try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {} };
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // texturable (CORS-clean) + matches the legacy branch
video.playsInline = true;
@ -2398,6 +2405,7 @@
// unconditionally releases the old currentVideoEl). Sets the REAL mute state here (warm-play was
// muted) and resumes from the snapshot frame, so there's no forward jump on reveal.
const mountVideo = () => {
if (stale()) { abandon(); return; } // a newer item took over during the wipe — don't clobber it
const c = document.getElementById('playerContainer');
teardownCurrentMedia(); // drop the outgoing frame (new video still detached)
c.style.display = 'block';
@ -2414,6 +2422,7 @@
const onFirstFrame = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
if (stale()) { abandon(); return; } // superseded before the wipe even started
try { video.pause(); } catch (e) {} // hold at the snapshot frame; mountVideo resumes from here
const canTexture = video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video);
let to = null;
@ -2447,12 +2456,14 @@
});
// Watchdog caps the wait so a cold/slow clip hard-cuts (mount+play) instead of the boundary hanging
// (mirrors renderImageBuffered's watchdog).
watchdog = setTimeout(() => { if (done) return; done = true; mountVideo(); }, 800);
watchdog = setTimeout(() => { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800);
video.src = src;
video.load();
}
function renderContent(item) {
// New dispatch supersedes any in-flight buffered (async warm-play) render — see renderSeq.
renderSeq++;
// Cancel any pending advance/refresh timer up front so a prior item's timer (incl. a
// self-rescheduling widget refresh) can't fire against the new content.
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }

View file

@ -603,6 +603,12 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
var self = this, stage = this.stage;
var from = this._texturableStageFrame(); // capture the outgoing frame NOW (playCurrent skipped clearStage)
var t = item.transition;
// The warm-play (first-frame wait + wipe) is async; if the current item changes mid-window (playlist
// push / advance), a stale clip must NOT clear the stage + mount over the newer content. Mirrors the
// stale() guard renderImage already uses.
var targetIdx = this.index;
var stale = function () { return self.index !== targetIdx || self.items[targetIdx] !== item; };
var abandon = function () { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} };
var v = document.createElement('video');
this.fit(v, item);
v.muted = true; v.setAttribute('playsinline', ''); // warm-play MUST be muted (autoplay policy)
@ -610,6 +616,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
v.style.cssText = '';
var done = false, watchdog = null;
var mountVideo = function () { // full clear + append + resume from the snapshot frame
if (stale()) { abandon(); return; } // a newer item took over during the wipe — don't clobber it
self.clearStage();
self.currentVideoEl = v; // wall/group drift-correct this (only after clearStage)
stage.appendChild(v);
@ -624,6 +631,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
var onFirstFrame = function () {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
if (stale()) { abandon(); return; } // superseded before the wipe even started
try { v.pause(); } catch (e) {} // hold at the snapshot frame; mountVideo resumes here
var w = stage.clientWidth || 1280, h = stage.clientHeight || 720;
var to = null;
@ -640,7 +648,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
};
v.addEventListener('loadeddata', function () { var p = v.play(); if (p && p.then) p.then(armFrame).catch(armFrame); else armFrame(); }, { once: true });
v.addEventListener('error', function () { if (done) return; done = true; if (watchdog) clearTimeout(watchdog); self.skipSoon(); });
watchdog = setTimeout(function () { if (done) return; done = true; mountVideo(); }, 800); // cold/slow clip -> hard cut
watchdog = setTimeout(function () { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800); // cold/slow clip -> hard cut
v.src = this.contentUrl(item);
v.load();
};