feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)

Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
This commit is contained in:
screentinker 2026-07-20 16:45:32 -05:00 committed by GitHub
parent af89eaa75b
commit 96b71a0d56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 3906 additions and 50 deletions

39
.github/workflows/shaders.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Shaders
on:
push:
branches: [main]
paths: ['shared/Transitions/**', 'package.json', '.github/workflows/shaders.yml']
pull_request:
branches: [main]
paths: ['shared/Transitions/**', 'package.json', '.github/workflows/shaders.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: shaders-${{ github.ref }}
cancel-in-progress: true
jobs:
compile:
name: Compile transition shaders (real WebGL)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
# Install Chrome and resolve its path explicitly — don't assume /usr/bin/google-chrome-stable
# exists on the runner image (it silently won't fail until a push, at the worst possible time).
- uses: browser-actions/setup-chrome@v1
id: chrome
with:
chrome-version: stable
# puppeteer-core only (Apache-2.0) — drives the Chrome above, downloads no browser binary.
- run: npm install --no-audit --no-fund
- name: Compile + link all transition shaders in headless Chrome (SwiftShader)
env:
PUPPETEER_EXECUTABLE_PATH: ${{ steps.chrome.outputs.chrome-path }}
run: npm run test:shaders

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ Thumbs.db
# Local-only marketing assets
video/
# Generated: transition demo (rebuild with `node shared/Transitions/build-demo.js`)
shared/Transitions/demo.html

View file

@ -29,6 +29,10 @@ WORKDIR /app/server
COPY server/ /app/server/
COPY --from=builder /app/server/node_modules /app/server/node_modules
COPY frontend/ /app/frontend/
# shared/Transitions is a RUNTIME dependency: server/lib/transition-config.js + transition-bundle.js
# require the shader manifest/params/sources from ../../shared at load time (the server won't boot
# without it). Small, and keeps the .glsl files the single source across server + player + Tizen.
COPY shared/ /app/shared/
COPY VERSION /app/VERSION
# the /openapi.yaml route serves ../docs/openapi.yaml (the spec Redoc on /docs fetches);
# without this it 404s in the image even though it serves fine from a dev checkout.

View file

@ -55,6 +55,11 @@ android {
// APIs return defaults instead of throwing "not mocked".
unitTests.isReturnDefaultValues = true
}
// feat/transition-engine: the GL transition shaders ship as assets, COPIED from shared/Transitions
// at build (the single source of truth — same .glsl the web bundle + Tizen build assemble from),
// so the native compositor can't drift from the other players. See copyTransitionShaders below.
sourceSets.getByName("main").assets.srcDir(layout.buildDirectory.dir("generated/transitionAssets"))
}
dependencies {
@ -90,8 +95,21 @@ dependencies {
// #74/#75: unit tests for the Kotlin schedule evaluator (vector drift guard)
testImplementation("junit:junit:4.13.2")
// feat/transition-engine: real org.json on the unit-test classpath (the stubbed android.jar one
// returns defaults with isReturnDefaultValues=true) so TransitionParseTest exercises actual parsing.
testImplementation("org.json:json:20231013")
}
// feat/transition-engine: copy the shared GL transition shaders into a generated assets dir so the
// native compositor loads the SAME .glsl the web/Tizen players do (no checked-in copy to drift). Wired
// ahead of asset merge for every variant.
val copyTransitionShaders by tasks.registering(Copy::class) {
from(File(rootProject.projectDir.parentFile, "shared/Transitions")) { include("*.glsl") }
into(layout.buildDirectory.dir("generated/transitionAssets/transitions"))
}
tasks.matching { it.name == "preBuild" || it.name.startsWith("merge") && it.name.endsWith("Assets") }
.configureEach { dependsOn(copyTransitionShaders) }
// #74/#75: point the evaluator drift-guard test at the SHARED vector contract
// (shared/schedule-vectors.json, the single source - no snapshot). rootProject is
// the android/ Gradle root; its parent is the repo root. Any ScheduleEval.kt edit

View file

@ -30,6 +30,7 @@ import androidx.media3.ui.PlayerView
import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.TransitionGLView
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.PipOverlay
@ -227,6 +228,16 @@ class MainActivity : AppCompatActivity() {
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId)
}
// feat/transition-engine: full-screen GLES2 overlay that plays image/video wipes. Inserted just
// BELOW the status overlay (so the connecting/idle screen still covers it) and ABOVE the image/
// video layers, so a wipe composites over the outgoing content. Hidden except during a wipe.
val transitionView = TransitionGLView(this)
(rootView as FrameLayout).let { root ->
val idx = root.indexOfChild(statusOverlay).coerceAtLeast(0)
root.addView(transitionView, idx,
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
}
// Setup media player
mediaPlayer = MediaPlayerManager(
context = this,
@ -237,7 +248,8 @@ class MainActivity : AppCompatActivity() {
onImageError = {
Log.w("MainActivity", "Image failed to load, skipping to next item")
handler.postDelayed({ playlistController.next() }, 500)
}
},
transitionView = transitionView
)
// Video-wall controller. The emit lambdas read wsService lazily (it's bound after
@ -852,9 +864,9 @@ class MainActivity : AppCompatActivity() {
if (item.isRemote) {
Log.i("MainActivity", "Playing remote content: ${item.remoteUrl}")
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted)
mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted) // remote video: plain (no wipe)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImageFromUrl(item.remoteUrl!!)
mediaPlayer.showImageFromUrl(item.remoteUrl!!, item.transition)
}
wsService?.sendPlaybackState(item.contentId, 0f)
return
@ -879,9 +891,9 @@ class MainActivity : AppCompatActivity() {
private fun playFile(item: PlaylistItem, file: java.io.File) {
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideo(file, item.muted)
mediaPlayer.playVideo(file, item.muted, item.transition)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImage(file)
mediaPlayer.showImage(file, item.transition)
}
// Report playback state

View file

@ -1,10 +1,16 @@
package com.remotedisplay.player.player
import android.content.Context
import android.graphics.Bitmap
import android.graphics.SurfaceTexture
import android.graphics.drawable.BitmapDrawable
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.TextureView
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
@ -25,8 +31,12 @@ class MediaPlayerManager(
private val imageView: ImageView,
private val youtubeWebView: WebView? = null,
private val onVideoComplete: () -> Unit,
private val onImageError: (() -> Unit)? = null
private val onImageError: (() -> Unit)? = null,
// feat/transition-engine: the full-screen GL overlay that plays a from->to wipe. Null = no
// transitions (every render hard-cuts, exactly as before).
private val transitionView: TransitionGLView? = null
) {
private val mainHandler = Handler(Looper.getMainLooper())
private var exoPlayer: ExoPlayer? = null
private var currentType: MediaType = MediaType.NONE
// Wall mode: followers must stay muted even as the leader's sync switches them
@ -83,6 +93,71 @@ class MediaPlayerManager(
// state and the IFrame API bridge can flip it without reloading the embed.
private var youtubeMuted = false
// ---- feat/transition-engine: GL wipe helpers. Every failure path returns false/null so the caller
// hard-cuts (never a blank frame). Solo fullscreen only — suppressed for wall followers and group/
// loop sync (they own their own frame timing). ----
private fun transitionsActive(): Boolean = transitionView != null && !wallMute && !videoLooping
// The frame on screen now, as a bitmap, for the wipe's `from`. Image -> the ImageView bitmap; video
// -> the ExoPlayer TextureView's current frame. Null (youtube/widget/none/unavailable) -> hard cut.
private fun captureCurrentFrame(): Bitmap? = try {
when (currentType) {
MediaType.IMAGE -> (imageView.drawable as? BitmapDrawable)?.bitmap
MediaType.VIDEO -> (playerView.videoSurfaceView as? TextureView)?.let { tv ->
if (tv.isAvailable && tv.width > 0 && tv.height > 0) tv.bitmap else null
}
else -> null
}
} catch (e: Throwable) { null }
// Pick one effect at random (variety) and resolve its wrapped fragment source + params. Null if the
// shader isn't in assets -> hard cut.
private fun pickEffect(spec: TransitionSpec): Pair<String, Map<String, Float>>? {
if (spec.effects.isEmpty()) return null
val idx = (Math.random() * spec.effects.size).toInt().coerceIn(0, spec.effects.size - 1)
val e = spec.effects[idx]
val src = TransitionGlsl.loadSource(context.assets, e.shader) ?: return null
return TransitionGlsl.fragmentFor(src) to e.params
}
// Run a from->to wipe, then `swap` (the plain mount) on completion. Returns false if it can't start
// (the caller must then swap immediately). `swap` is the SAME plain mount the no-transition path uses.
private fun runWipe(toBitmap: Bitmap, spec: TransitionSpec?, from: Bitmap?, swap: () -> Unit): Boolean {
val view = transitionView
if (view == null || spec == null || from == null || !transitionsActive()) return false
val picked = pickEffect(spec) ?: return false
val w = ImageLoader.screenWidth(context); val h = ImageLoader.screenHeight(context)
if (w <= 0 || h <= 0) return false
val fromFit: Bitmap; val toFit: Bitmap
try { fromFit = fitTransitionBitmap(from, w, h); toFit = fitTransitionBitmap(toBitmap, w, h) }
catch (e: Throwable) { Log.w("MediaPlayerManager", "wipe fit failed: ${e.message}"); return false }
view.play(fromFit, toFit, picked.first, picked.second, spec.durationMs) { swap() }
return true
}
// Extract a local video's first frame as a bitmap (the wipe's `to` for image->video / video->video).
// Blocking — call off the main thread. Null on any failure -> hard cut.
private fun extractFirstFrame(path: String): Bitmap? {
val r = MediaMetadataRetriever()
return try {
r.setDataSource(path)
r.getFrameAtTime(0L, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} catch (e: Throwable) { Log.w("MediaPlayerManager", "first-frame extract failed: ${e.message}"); null }
finally { try { r.release() } catch (_: Throwable) {} }
}
// Plain image mount (visibility flip + set bitmap). Shared by the transition-done swap and the
// no-transition hard cut.
private fun mountImageBitmap(bitmap: Bitmap) {
currentType = MediaType.IMAGE
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.stop()
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
}
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
currentType = MediaType.YOUTUBE
@ -151,26 +226,18 @@ class MediaPlayerManager(
}
}
fun showImageFromUrl(url: String) {
fun showImageFromUrl(url: String, transition: TransitionSpec? = null) {
Log.i("MediaPlayerManager", "Loading remote image: $url")
currentType = MediaType.IMAGE
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.stop()
// Capture the outgoing frame NOW, on the main thread, before the decode thread swaps it out.
val from = if (transition != null) captureCurrentFrame() else null
Thread {
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
if (bitmap != null) {
imageView.post {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
mainHandler.post {
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
onImageError?.invoke(); return@post
}
} else {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
imageView.post { onImageError?.invoke() }
if (!runWipe(bitmap, transition, from) { mountImageBitmap(bitmap) }) mountImageBitmap(bitmap)
}
}.start()
}
@ -196,7 +263,28 @@ class MediaPlayerManager(
Log.i("MediaPlayerManager", "Preloaded next video: ${file.name}")
}
fun playVideo(file: File, muted: Boolean = false) {
fun playVideo(file: File, muted: Boolean = false, transition: TransitionSpec? = null) {
// image->video / video->video wipe: extract the incoming clip's first frame OFF the main thread,
// wipe from the outgoing frame into it, then warm-mount the real video (which starts from frame 0,
// matching the wipe's `to`). Any failure hard-cuts to the plain mount. Local files only — remote
// streams (playVideoFromUrl) keep the plain path.
if (transition != null && transitionsActive()) {
val from = captureCurrentFrame()
if (from != null) {
Thread {
val toBmp = extractFirstFrame(file.absolutePath)
mainHandler.post {
if (toBmp != null && runWipe(toBmp, transition, from) { mountVideo(file, muted) }) return@post
mountVideo(file, muted)
}
}.start()
return
}
}
mountVideo(file, muted)
}
private fun mountVideo(file: File, muted: Boolean = false) {
currentType = MediaType.VIDEO
// Show player, hide image
@ -233,28 +321,18 @@ class MediaPlayerManager(
}
}
fun showImage(file: File) {
fun showImage(file: File, transition: TransitionSpec? = null) {
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
currentType = MediaType.IMAGE
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.stop()
val bitmap = ImageLoader.decodeFile(file, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable image: ${file.name}")
onImageError?.invoke()
return
}
try {
imageView.setImageBitmap(bitmap)
} catch (e: Throwable) {
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}")
onImageError?.invoke()
}
// Capture the outgoing frame (image or the video's TextureView) BEFORE the swap; decode above
// doesn't touch the views, so it still reflects what's on screen. Wipe into the image, else hard cut.
val from = if (transition != null) captureCurrentFrame() else null
if (!runWipe(bitmap, transition, from) { mountImageBitmap(bitmap) }) mountImageBitmap(bitmap)
}
fun stop() {

View file

@ -20,7 +20,9 @@ data class PlaylistItem(
val muted: Boolean = false,
val widgetId: String? = null,
val widgetType: String? = null,
val schedules: List<ScheduleEval.Block> = emptyList()
val schedules: List<ScheduleEval.Block> = emptyList(),
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
val transition: TransitionSpec? = null
) {
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
// Widget assignments have a widget_id and no downloadable content file.
@ -130,7 +132,8 @@ class PlaylistController(
muted = obj.optInt("muted", 0) == 1,
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
schedules = parseSchedules(obj.optJSONArray("schedules"))
schedules = parseSchedules(obj.optJSONArray("schedules")),
transition = Transitions.parse(obj.optJSONObject("transition"))
)
)
}
@ -146,10 +149,12 @@ class PlaylistController(
// deliberately EXCLUDED so a duration-only edit does NOT force a restart; instead it's applied
// IN PLACE below (the #group-sync schedule tick + the solo advance timer read durationSec live),
// so timing edits take effect without interrupting playback or resetting the index.
// transition included so a transition-only edit re-renders instead of being de-duped (a
// cached-playlist device otherwise silently ignores it — the web/Tizen fingerprint bug).
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
}
} + "|" + (it.transition?.sig() ?: "")
val oldContentIds = items.map(::sig)
val newContentIds = newItems.map(::sig)
val playlistChanged = oldContentIds != newContentIds

View file

@ -0,0 +1,59 @@
package com.remotedisplay.player.player
import org.json.JSONObject
// feat/transition-engine (native Android port). The server normalizes a transition WIDGET into an
// opaque per-item `transition` object on the device payload — identical shape across web/Tizen/Android:
//
// "transition": { "effects": [ { "shader": "CRTCollapse", "params": { "lineHold": 0.2, ... } } ],
// "durationMs": 800 }
//
// Params are already RESOLVED + CLAMPED server-side (every declared uniform present), so the player just
// uploads shader source + sets uniforms — no manifest/param schema needed on the device. A transition may
// carry SEVERAL effects; the compositor picks one at random per advance for variety.
data class TransitionEffect(val shader: String, val params: Map<String, Float>)
data class TransitionSpec(val effects: List<TransitionEffect>, val durationMs: Int) {
// Stable, structural signature for the playlist change-fingerprint. A transition edit (shader, param,
// or duration) MUST change this string, or a cached-playlist device silently ignores the update —
// the exact bug that made transitions "never apply" on the web + Tizen players until the fingerprint
// included the transition. Effects are order-significant; params sorted for determinism.
fun sig(): String = effects.joinToString(",") { e ->
e.shader + "(" + e.params.toSortedMap().entries.joinToString(";") { "${it.key}=${it.value}" } + ")"
} + "@" + durationMs
}
object Transitions {
private const val MIN_MS = 150
private const val MAX_MS = 3000
private const val DEFAULT_MS = 800
// Parse the per-item `transition` object off an assignment. Tolerant + defensive: an absent/empty/
// malformed object -> null (the renderer hard-cuts, never a black frame), mirroring the server's
// "unknown shader -> no transition" contract. Duration is bounded even though the server clamps it.
fun parse(obj: JSONObject?): TransitionSpec? {
if (obj == null) return null
val arr = obj.optJSONArray("effects") ?: return null
val effects = ArrayList<TransitionEffect>(arr.length())
for (i in 0 until arr.length()) {
val e = arr.optJSONObject(i) ?: continue
val shader = e.optString("shader", "")
if (shader.isEmpty()) continue
val params = HashMap<String, Float>()
e.optJSONObject("params")?.let { p ->
val keys = p.keys()
while (keys.hasNext()) {
val k = keys.next() as? String ?: continue
val v = p.optDouble(k, Double.NaN)
if (!v.isNaN()) params[k] = v.toFloat()
}
}
effects.add(TransitionEffect(shader, params))
}
if (effects.isEmpty()) return null
var durationMs = obj.optInt("durationMs", DEFAULT_MS)
durationMs = durationMs.coerceIn(MIN_MS, MAX_MS)
return TransitionSpec(effects, durationMs)
}
}

View file

@ -0,0 +1,231 @@
package com.remotedisplay.player.player
import android.content.Context
import android.content.res.AssetManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PixelFormat
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.GLUtils
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
// feat/transition-engine — native GLES2 compositor (GL Transitions v1), the Android sibling of the web
// player's runGlWipe + shared/Transitions/renderer.js. It composites TWO frames (from -> to) across
// `progress` 0..1 using the SAME .glsl shaders and the SAME uniform contract as web/Tizen. Every failure
// path calls onDone immediately so the caller hard-cuts — never a blank frame.
// The GLSL wrap — MUST stay byte-identical to shared/Transitions/params.js (the shader sources assume
// exactly these names). uFrom holds the outgoing frame for the whole wipe, so there's never a blank seam.
object TransitionGlsl {
const val PREAMBLE = "precision highp float;\n" +
"varying vec2 vUv;\n" +
"uniform sampler2D uFrom;\n" +
"uniform sampler2D uTo;\n" +
"uniform float progress;\n" +
"uniform float ratio;\n" +
"vec4 getFromColor(vec2 uv){ return texture2D(uFrom, uv); }\n" +
"vec4 getToColor(vec2 uv){ return texture2D(uTo, uv); }\n"
const val EPILOGUE = "\nvoid main(){ gl_FragColor = transition(vUv); }"
const val VERTEX = "attribute vec2 aPos;\n" +
"varying vec2 vUv;\n" +
"void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }"
fun fragmentFor(shaderSrc: String): String = PREAMBLE + "\n" + shaderSrc + "\n" + EPILOGUE
// Load a shader's GLSL source by id from assets/transitions/<id>.glsl (copied from shared/Transitions
// at build). Returns null if missing -> the caller hard-cuts (never a black frame).
fun loadSource(assets: AssetManager, shaderId: String): String? = try {
assets.open("transitions/$shaderId.glsl").bufferedReader().use { it.readText() }
} catch (e: Throwable) { Log.w("TransitionGL", "shader '$shaderId' not found in assets: ${e.message}"); null }
}
// Fit a source bitmap into a w×h frame with object-fit:contain letterboxing (matches the static
// ImageView/PlayerView framing), AND flip it vertically — GLES2 has no UNPACK_FLIP_Y_WEBGL, so the flip
// here replicates exactly what the web renderer's upload() does, keeping the shader uv convention (and
// therefore the transition geometry) identical across platforms. Returns an ARGB_8888 bitmap.
fun fitTransitionBitmap(src: Bitmap, w: Int, h: Int): Bitmap {
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(out)
c.drawColor(android.graphics.Color.BLACK)
val iw = src.width.toFloat(); val ih = src.height.toFloat()
if (iw > 0f && ih > 0f) {
val s = minOf(w / iw, h / ih) // contain
val dw = iw * s; val dh = ih * s
val m = Matrix()
m.postScale(s, s)
m.postTranslate((w - dw) / 2f, (h - dh) / 2f)
m.postScale(1f, -1f, w / 2f, h / 2f) // vertical flip == UNPACK_FLIP_Y_WEBGL
c.drawBitmap(src, m, Paint(Paint.FILTER_BITMAP_FLAG))
}
return out
}
/**
* Full-screen GLES2 overlay that plays one from->to wipe and then hides itself. Attached above the
* image/video layers; translucent + z-order-on-top so the frame BEHIND it shows through until the first
* opaque wipe frame paints (no black flash on show). GLSurfaceView manages EGL + the render thread.
*/
class TransitionGLView(context: Context) : GLSurfaceView(context) {
// A single wipe request. onDone runs on the MAIN thread when the wipe completes OR fails (never-blank:
// the caller swaps in the real content there). failed/startNs are GL-thread-only after pickup.
private class Job(
val from: Bitmap,
val to: Bitmap,
val fragmentSrc: String,
val params: Map<String, Float>,
val durationMs: Int,
val onDone: () -> Unit
) { var startNs = 0L; var failed = false }
private val renderer = TxRenderer()
@Volatile private var incoming: Job? = null
init {
setEGLContextClientVersion(2)
setEGLConfigChooser(8, 8, 8, 8, 0, 0) // alpha channel -> translucent surface
holder.setFormat(PixelFormat.TRANSLUCENT)
setZOrderOnTop(true) // above the content views while a wipe is in flight
setRenderer(renderer)
renderMode = RENDERMODE_WHEN_DIRTY
visibility = GONE
}
/** Main-thread entry: run a wipe. If the runtime can't start it, onDone still fires (hard cut). */
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
visibility = VISIBLE
renderMode = RENDERMODE_CONTINUOUSLY
requestRender()
}
private inner class TxRenderer : Renderer {
private var vShader = 0
private var program = 0
private var texFrom = 0
private var texTo = 0
private var uFrom = 0; private var uTo = 0; private var uProgress = 0; private var uRatio = 0
private val uParam = HashMap<String, Int>()
private var vw = 1; private var vh = 1
private var active: Job? = null
private val quad: FloatBuffer = ByteBuffer
.allocateDirect(8 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
.apply { put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)); position(0) }
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) }
active = null; program = 0; texFrom = 0; texTo = 0
}
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
val j = active
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
if (j == null) return
if (j.failed) { finish(j); return }
if (j.startNs == 0L) j.startNs = System.nanoTime()
val p = ((System.nanoTime() - j.startNs).toFloat() / (j.durationMs * 1_000_000f)).coerceIn(0f, 1f)
draw(j, p)
if (p >= 1f) finish(j)
}
// Compile + link the program and upload both frames as textures. Any failure -> job.failed
// (onDrawFrame then finishes it -> onDone hard-cuts). Never throws out of here.
private fun setup(j: Job) {
active = j
try {
val frag = compile(GLES20.GL_FRAGMENT_SHADER, j.fragmentSrc)
val prog = GLES20.glCreateProgram()
GLES20.glAttachShader(prog, vShader)
GLES20.glAttachShader(prog, frag)
GLES20.glBindAttribLocation(prog, 0, "aPos")
GLES20.glLinkProgram(prog)
val ok = IntArray(1); GLES20.glGetProgramiv(prog, GLES20.GL_LINK_STATUS, ok, 0)
GLES20.glDeleteShader(frag)
if (ok[0] == 0) { val log = GLES20.glGetProgramInfoLog(prog); GLES20.glDeleteProgram(prog); throw RuntimeException("link: $log") }
program = prog
uFrom = GLES20.glGetUniformLocation(prog, "uFrom")
uTo = GLES20.glGetUniformLocation(prog, "uTo")
uProgress = GLES20.glGetUniformLocation(prog, "progress")
uRatio = GLES20.glGetUniformLocation(prog, "ratio")
uParam.clear()
for (name in j.params.keys) uParam[name] = GLES20.glGetUniformLocation(prog, name)
texFrom = uploadTexture(j.from)
texTo = uploadTexture(j.to)
} catch (e: Throwable) {
Log.w("TransitionGL", "wipe setup failed, hard-cutting: ${e.message}")
j.failed = true
}
}
private fun draw(j: Job, p: Float) {
GLES20.glUseProgram(program)
GLES20.glViewport(0, 0, vw, vh)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texFrom); GLES20.glUniform1i(uFrom, 0)
GLES20.glActiveTexture(GLES20.GL_TEXTURE1); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texTo); GLES20.glUniform1i(uTo, 1)
GLES20.glUniform1f(uProgress, p)
GLES20.glUniform1f(uRatio, vw.toFloat() / maxOf(1, vh))
for ((name, v) in j.params) { val loc = uParam[name] ?: -1; if (loc >= 0) GLES20.glUniform1f(loc, v) }
GLES20.glEnableVertexAttribArray(0)
quad.position(0)
GLES20.glVertexAttribPointer(0, 2, GLES20.GL_FLOAT, false, 0, quad)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(0)
}
// Release GL objects, then run the job's onDone on the main thread (the content swap happens there).
private fun finish(j: Job) {
active = null
releaseGl()
finishOnMain(j)
}
private fun finishOnMain(j: Job) { post { j.onDone() } } // View.post -> main thread; guarded once by active handoff
private fun releaseGl() {
if (texFrom != 0) { GLES20.glDeleteTextures(1, intArrayOf(texFrom), 0); texFrom = 0 }
if (texTo != 0) { GLES20.glDeleteTextures(1, intArrayOf(texTo), 0); texTo = 0 }
if (program != 0) { GLES20.glDeleteProgram(program); program = 0 }
}
private fun uploadTexture(bmp: Bitmap): Int {
val ids = IntArray(1); GLES20.glGenTextures(1, ids, 0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0])
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bmp, 0)
return ids[0]
}
private fun compile(type: Int, src: String): Int {
val s = GLES20.glCreateShader(type)
GLES20.glShaderSource(s, src)
GLES20.glCompileShader(s)
val ok = IntArray(1); GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, ok, 0)
if (ok[0] == 0) { val log = GLES20.glGetShaderInfoLog(s); GLES20.glDeleteShader(s); throw RuntimeException("compile: $log") }
return s
}
}
}

View file

@ -0,0 +1,80 @@
package com.remotedisplay.player.player
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* feat/transition-engine (native Android port). Two guarantees:
* 1. Transitions.parse() is tolerant a valid object resolves; anything malformed/empty -> null so the
* compositor hard-cuts (never a black frame), mirroring the server's "unknown shader -> no transition".
* 2. TransitionSpec.sig() makes a transition edit change the playlist fingerprint the exact fix for the
* bug that made transitions "never apply" on cached-playlist devices (web/Tizen) until the fingerprint
* included the transition.
*/
class TransitionParseTest {
private fun spec(json: String): TransitionSpec? = Transitions.parse(JSONObject(json))
@Test fun `valid transition resolves effects, params and duration`() {
val t = spec("""{"effects":[{"shader":"CRTCollapse","params":{"lineHold":0.2,"flashGain":1.0}}],"durationMs":800}""")!!
assertEquals(1, t.effects.size)
assertEquals("CRTCollapse", t.effects[0].shader)
assertEquals(0.2f, t.effects[0].params["lineHold"])
assertEquals(1.0f, t.effects[0].params["flashGain"])
assertEquals(800, t.durationMs)
}
@Test fun `multiple effects are preserved in order`() {
val t = spec("""{"effects":[{"shader":"CRTCollapse","params":{}},{"shader":"Etch","params":{}}],"durationMs":500}""")!!
assertEquals(listOf("CRTCollapse", "Etch"), t.effects.map { it.shader })
}
@Test fun `effect with no shader is dropped, all-empty yields null (hard cut, never black)`() {
assertNull(spec("""{"effects":[{"params":{"x":1}}],"durationMs":800}"""))
assertNull(spec("""{"effects":[],"durationMs":800}"""))
assertNull(spec("""{"durationMs":800}""")) // no effects array
assertNull(Transitions.parse(null)) // absent transition
}
@Test fun `duration is bounded even if the payload is out of range`() {
assertEquals(3000, spec("""{"effects":[{"shader":"Etch","params":{}}],"durationMs":999999}""")!!.durationMs)
assertEquals(150, spec("""{"effects":[{"shader":"Etch","params":{}}],"durationMs":1}""")!!.durationMs)
assertEquals(800, spec("""{"effects":[{"shader":"Etch","params":{}}]}""")!!.durationMs) // default
}
// ===== the fingerprint (THE bug) =====
@Test fun `sig is stable regardless of param key order`() {
val a = spec("""{"effects":[{"shader":"CRTCollapse","params":{"a":1,"b":2}}],"durationMs":800}""")!!
val b = spec("""{"effects":[{"shader":"CRTCollapse","params":{"b":2,"a":1}}],"durationMs":800}""")!!
assertEquals("param-order must not change the signature (else spurious re-renders)", a.sig(), b.sig())
}
@Test fun `a shader, param, or duration change all change the signature`() {
val base = spec("""{"effects":[{"shader":"CRTCollapse","params":{"lineHold":0.2}}],"durationMs":800}""")!!
val shader = spec("""{"effects":[{"shader":"Etch","params":{"lineHold":0.2}}],"durationMs":800}""")!!
val param = spec("""{"effects":[{"shader":"CRTCollapse","params":{"lineHold":0.5}}],"durationMs":800}""")!!
val dur = spec("""{"effects":[{"shader":"CRTCollapse","params":{"lineHold":0.2}}],"durationMs":1200}""")!!
assertNotEquals(base.sig(), shader.sig())
assertNotEquals(base.sig(), param.sig())
assertNotEquals(base.sig(), dur.sig())
}
@Test fun `adding or changing a transition flips the item signature (would-be de-dup is broken)`() {
// Reproduces the cached-playlist bug at the PlaylistController level: same content, only the
// transition differs -> the item signature MUST differ so the update isn't silently dropped.
fun itemSig(t: TransitionSpec?): String =
"cid|" + "" + "|" + "" + "|" + "" + "|" + (t?.sig() ?: "") // mirrors PlaylistController.sig() shape
val none = itemSig(null)
val withTx = itemSig(spec("""{"effects":[{"shader":"CRTCollapse","params":{}}],"durationMs":800}"""))
val otherTx = itemSig(spec("""{"effects":[{"shader":"Etch","params":{}}],"durationMs":800}"""))
assertNotEquals("adding a transition must change the fingerprint", none, withTx)
assertNotEquals("swapping the shader must change the fingerprint", withTx, otherTx)
assertTrue(none.endsWith("|")) // no transition -> empty suffix
}
}

View file

@ -704,6 +704,21 @@ export default {
'widget.type.directory_board.desc': 'Scrolling tenant/room directory for lobbies',
'widget.type.directory_search.name': 'Directory Search',
'widget.type.directory_search.desc': 'Walk-up search of a directory board',
'widget.type.transition.name': 'Transition',
'widget.type.transition.desc': 'Animated crossover between playlist items',
// transition widget
'widget.trans.shader': 'Effect',
'widget.trans.multi_hint': 'Pick one, or several — with more than one, the player uses a different effect each time (click a name to preview and tune it).',
'widget.trans.params': 'Parameters',
'widget.trans.duration': 'Duration (ms)',
'widget.trans.scope': 'Applies to',
'widget.trans.scope_next': 'Only the next item (advanced)',
'widget.trans.scope_all': 'Every item in the playlist (recommended)',
'widget.trans.scope_hint': 'One transition covers the whole playlist — drop this widget in anywhere and every image will transition. Use “only the next item” if you want a different effect at one specific spot.',
'widget.trans.play': 'Play',
'widget.trans.pause': 'Pause',
'widget.trans.unavailable': 'Transition preview unavailable',
'widget.trans.compile_error': 'Shader failed to compile',
// directory-search widget
'widget.dirsearch.source_label': 'Directory board',
'widget.dirsearch.source_hint': 'Reads entries live from the selected board — nothing is copied.',

View file

@ -6,7 +6,7 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type':
// Widget type ids only — name + desc are looked up via t() so they switch
// language with the rest of the UI.
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search'];
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search', 'transition'];
const WIDGET_ICONS = {
clock: '&#128339;',
weather: '&#9925;',
@ -16,6 +16,7 @@ const WIDGET_ICONS = {
social: '&#128172;',
'directory-board': '&#127970;',
'directory-search': '&#128269;',
transition: '&#127916;',
};
const widgetTypeName = (id) => t(`widget.type.${id.replace(/-/g, '_')}.name`);
const widgetTypeDesc = (id) => t(`widget.type.${id.replace(/-/g, '_')}.desc`);
@ -476,12 +477,41 @@ export async function render(container) {
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="wKeyboard" ${config.show_onscreen_keyboard === false ? '' : 'checked'}> ${t('widget.dirsearch.keyboard_label')}</label></div>`;
break;
}
case 'transition':
html += `
<div class="form-group"><label>${t('widget.trans.shader')}</label>
<div id="wTransList" style="max-height:158px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:4px;background:var(--bg-input)"></div>
<div class="hint" style="font-size:11px;color:var(--text-muted);margin-top:6px">${t('widget.trans.multi_hint')}</div>
<div id="wTransBlurb" style="font-size:11px;color:var(--text-muted);margin-top:4px"></div></div>
<div class="form-group">
<div style="position:relative;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:16/9">
<canvas id="wTransCanvas" style="width:100%;height:100%;display:block"></canvas>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:8px">
<button type="button" id="wTransPlay" class="btn btn-secondary" style="min-width:84px">${t('widget.trans.play')}</button>
<input type="range" id="wTransScrub" min="0" max="1000" value="0" style="flex:1;accent-color:var(--accent)">
</div>
<div id="wTransProgress" style="font-size:11px;color:var(--text-muted);text-align:right;margin-top:2px">0.00</div>
</div>
<div class="form-group"><label>${t('widget.trans.params')}</label><div id="wTransParams"></div></div>
<div class="form-group" style="max-width:220px"><label>${t('widget.trans.duration')}</label>
<input type="number" id="wTransDuration" class="input" value="${config.durationMs || 800}" min="150" max="3000" step="50"></div>
<div class="form-group" style="max-width:300px"><label>${t('widget.trans.scope')}</label>
<select id="wTransScope" class="input" style="background:var(--bg-input)">
<option value="all" ${config.scope !== 'next' ? 'selected' : ''}>${t('widget.trans.scope_all')}</option>
<option value="next" ${config.scope === 'next' ? 'selected' : ''}>${t('widget.trans.scope_next')}</option>
</select>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">${t('widget.trans.scope_hint')}</div></div>`;
break;
}
document.getElementById('widgetConfigForm').innerHTML = html;
const modalEl = document.querySelector('#widgetModal .modal');
if (modalEl) modalEl.style.width = type === 'directory-board' ? '720px' : '560px';
if (modalEl) modalEl.style.width = type === 'directory-board' ? '720px' : (type === 'transition' ? '620px' : '560px');
document.getElementById('widgetModal').style.display = 'flex';
// Transitions carry their own live preview, so the iframe "Preview" button doesn't apply.
const pvBtn = document.getElementById('previewWidgetBtn');
if (pvBtn) pvBtn.style.display = (type === 'transition') ? 'none' : '';
if (type === 'directory-board') {
dirState.logo_url = config.logo_url || '';
@ -512,6 +542,127 @@ export async function render(container) {
dirState.logo_url = config.logo_url || '';
renderLogoPicker();
}
if (type === 'transition') initTransitionForm(config);
}
// Live transition picker: a CHECKLIST of effects (pick one or several — the player randomizes among
// the chosen set per advance), a WebGL preview of the focused effect crossing two placeholder images,
// param sliders (from the shader's declared ranges) + duration + scope. Uses the same runtime the
// player ships (/player/transitions.js), so the preview IS the shipping renderer.
let transState = { renderer: null, from: null, to: null, raf: 0, playing: false, t0: 0, params: {}, focus: null };
function ensureTransitionRuntime() {
if (window.TransitionRenderer && window.__TRANSITION_MANIFEST) return Promise.resolve(true);
return new Promise((resolve) => {
const s = document.createElement('script');
s.src = '/player/transitions.js';
s.onload = () => resolve(true);
s.onerror = () => resolve(false);
document.head.appendChild(s);
});
}
function transPlaceholder(color, label) {
const c = document.createElement('canvas'); c.width = 640; c.height = 360;
const cx = c.getContext('2d');
const g = cx.createLinearGradient(0, 0, 640, 360);
g.addColorStop(0, color[0]); g.addColorStop(1, color[1]); cx.fillStyle = g; cx.fillRect(0, 0, 640, 360);
cx.fillStyle = 'rgba(255,255,255,.9)'; cx.textBaseline = 'middle';
cx.font = '700 64px system-ui,sans-serif'; cx.fillText(label, 44, 176);
return c;
}
async function initTransitionForm(config) {
const canvas = document.getElementById('wTransCanvas');
const list = document.getElementById('wTransList');
const blurb = document.getElementById('wTransBlurb');
const scrub = document.getElementById('wTransScrub');
const progLbl = document.getElementById('wTransProgress');
const playBtn = document.getElementById('wTransPlay');
const paramsBox = document.getElementById('wTransParams');
if (!canvas || !list) return;
const ready = await ensureTransitionRuntime();
if (!ready || !window.__TRANSITION_MANIFEST) { blurb.textContent = t('widget.trans.unavailable'); return; }
const MAN = window.__TRANSITION_MANIFEST;
const byId = (id) => MAN.find((x) => x.id === id);
// initial selection: config.shaders, else legacy single config.shader, else the first effect
const initialSel = (Array.isArray(config.shaders) && config.shaders.length ? config.shaders
: (config.shader ? [config.shader] : [MAN[0].id])).filter(byId);
transState.params = {}; // id -> resolved param values (lazily seeded)
canvas.width = 640; canvas.height = 360;
try {
transState.renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, { preserveDrawingBuffer: true });
transState.from = transPlaceholder(['#f97316', '#b91c1c'], 'A');
transState.to = transPlaceholder(['#0ea5e9', '#155e75'], 'B');
transState.renderer.setFrom(transState.from);
transState.renderer.setTo(transState.to);
} catch (e) { blurb.textContent = t('widget.trans.unavailable'); return; }
const paramsFor = (id) => {
if (transState.params[id]) return transState.params[id];
const m = byId(id);
const stored = (config.params && config.params[id]) || (config.shader === id && config.params) || {};
transState.params[id] = window.TransitionParams.resolveParams(
m.params.map((pp) => ({ name: pp.name, default: pp.default, min: pp.min, max: pp.max })), stored);
return transState.params[id];
};
const render = () => {
if (!transState.focus) return;
const p = (+scrub.value) / 1000; progLbl.textContent = p.toFixed(2);
try { transState.renderer.render(p, paramsFor(transState.focus)); } catch (e) {}
};
const buildSliders = (id) => {
const m = byId(id), vals = paramsFor(id);
paramsBox.innerHTML = '';
m.params.forEach((pp) => {
const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:6px';
const lab = document.createElement('label'); lab.textContent = pp.name; lab.style.cssText = 'font-size:12px;color:var(--text-muted);min-width:104px';
const r = document.createElement('input'); r.type = 'range'; r.min = pp.min; r.max = pp.max; r.step = (pp.max - pp.min) / 200 || 0.001; r.value = vals[pp.name]; r.style.cssText = 'flex:1;accent-color:var(--accent)';
const v = document.createElement('span'); v.textContent = (+vals[pp.name]).toFixed(2); v.style.cssText = 'font:600 11px ui-monospace,monospace;color:var(--text-muted);min-width:44px;text-align:right';
r.oninput = () => { vals[pp.name] = +r.value; v.textContent = (+r.value).toFixed(2); render(); };
row.appendChild(lab); row.appendChild(r); row.appendChild(v); paramsBox.appendChild(row);
});
};
const focusShader = (id) => {
transState.focus = id;
const m = byId(id); blurb.textContent = m ? (m.blurb || '') : '';
list.querySelectorAll('[data-focus]').forEach((el) => { el.style.fontWeight = el.dataset.focus === id ? '700' : '400'; });
try { transState.renderer.setShader(window.__TRANSITION_SHADERS[id]); }
catch (e) { blurb.textContent = t('widget.trans.compile_error'); return; }
buildSliders(id); render();
};
list.innerHTML = MAN.map((m) => `
<label style="display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer">
<input type="checkbox" data-id="${escAttr(m.id)}" ${initialSel.includes(m.id) ? 'checked' : ''}>
<span data-focus="${escAttr(m.id)}" style="flex:1">${escAttr(m.name)}</span>
</label>`).join('');
list.querySelectorAll('input[type=checkbox]').forEach((cb) => {
cb.onchange = () => { if (cb.checked) focusShader(cb.dataset.id); };
});
// clicking the NAME previews/tunes it; preventDefault so the label doesn't also toggle its checkbox
list.querySelectorAll('[data-focus]').forEach((sp) => { sp.onclick = (e) => { e.preventDefault(); focusShader(sp.dataset.focus); }; });
scrub.oninput = render;
// auto-play loop (eased, with holds); auto-stops when the modal closes (canvas leaves the DOM)
const DUR = 1600, HOLD = 500;
const loop = (ts) => {
if (!document.body.contains(canvas)) { transState.playing = false; return; }
if (!transState.t0) transState.t0 = ts;
const cycle = DUR + HOLD * 2, e = (ts - transState.t0) % cycle;
let p = e < HOLD ? 0 : e > HOLD + DUR ? 1 : (e - HOLD) / DUR;
p = p <= 0 ? 0 : p >= 1 ? 1 : (1 - Math.cos(p * Math.PI)) / 2;
scrub.value = Math.round(p * 1000); render();
if (transState.playing) transState.raf = requestAnimationFrame(loop);
};
playBtn.onclick = () => {
transState.playing = !transState.playing;
playBtn.textContent = transState.playing ? t('widget.trans.pause') : t('widget.trans.play');
if (transState.playing) { transState.t0 = 0; transState.raf = requestAnimationFrame(loop); }
else cancelAnimationFrame(transState.raf);
};
focusShader(initialSel[0] || MAN[0].id);
}
function renderDirCategories(opts = {}) {
@ -770,6 +921,18 @@ export async function render(container) {
case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break;
case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break;
case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break;
case 'transition': {
const shaders = Array.from(document.querySelectorAll('#wTransList input[type=checkbox]:checked')).map(c => c.dataset.id);
const params = {}; // per-shader tuned values held in transState.params
shaders.forEach(id => { if (transState.params[id]) params[id] = transState.params[id]; });
Object.assign(config, {
shaders,
params,
durationMs: parseInt(val('wTransDuration')) || 800,
scope: val('wTransScope') || 'all',
});
break;
}
case 'directory-board': Object.assign(config, {
title: val('wTitle') || ' ',
logo_url: dirState.logo_url || '',

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "screentinker-shared",
"version": "0.0.0",
"private": true,
"description": "Cross-cutting tooling for ScreenTinker shared assets (transition shader library, etc.)",
"license": "MIT",
"scripts": {
"build:manifest": "node shared/Transitions/generate-manifest.js",
"test:shaders": "node shared/Transitions/compile-test.js"
},
"devDependencies": {
"puppeteer-core": "^23.11.1"
}
}

137
server/lib/ssrf-guard.js Normal file
View file

@ -0,0 +1,137 @@
'use strict';
// SSRF guard for the media proxy. The proxy fetches a customer-supplied URL and re-serves it
// same-origin so a <canvas>/WebGL transition can read it. That makes it an open fetch primitive
// running on the box that also serves the dashboard — so it MUST NOT be reachable to internal /
// loopback / link-local / cloud-metadata targets. We therefore (1) allow only http/https, (2) DNS-
// resolve the host and reject if ANY resolved address is private/reserved (multi-A rebinding), and
// (3) return the vetted addresses so the caller PINS the socket to one of them — a re-resolve at
// connect time can't be rebound to 127.0.0.1/169.254.169.254 after we vetted it. Redirects are
// re-vetted the same way (the caller re-invokes assertSafeUrl on each hop).
const dns = require('dns').promises;
const net = require('net');
class SsrfError extends Error {
constructor(reason) { super('blocked: ' + reason); this.name = 'SsrfError'; this.reason = reason; }
}
// ---- IPv4 ----
function v4ToInt(ip) {
const p = ip.split('.');
if (p.length !== 4) return null;
let n = 0;
for (const part of p) {
const b = Number(part);
if (!Number.isInteger(b) || b < 0 || b > 255 || !/^\d{1,3}$/.test(part)) return null;
n = (n * 256) + b;
}
return n >>> 0;
}
function inV4(ip, cidr) {
const [base, bitsStr] = cidr.split('/');
const ipn = v4ToInt(ip), basen = v4ToInt(base);
if (ipn === null || basen === null) return false;
const bits = Number(bitsStr);
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (ipn & mask) === (basen & mask);
}
// 0.0.0.0/8 (this host), 10/8, 100.64/10 (CGNAT), 127/8 (loopback), 169.254/16 (link-local incl.
// cloud metadata 169.254.169.254), 172.16/12, 192.0.0/24, 192.0.2/24, 192.88.99/24, 192.168/16,
// 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 (multicast), 240/4 (reserved/broadcast).
const V4_BLOCK = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
];
function isBlockedV4(ip) { return v4ToInt(ip) === null || V4_BLOCK.some((c) => inV4(ip, c)); }
// ---- IPv6 ----
// Expand any IPv6 text form (compressed ::, dotted-quad tail, hex) to 8 numeric hextets, or null.
function expandV6(ip) {
let s = ip.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0]; // strip brackets / zone id
const dotted = s.match(/^(.*:)((?:\d{1,3}\.){3}\d{1,3})$/); // trailing embedded v4 -> 2 hextets
if (dotted) {
const v = dotted[2].split('.').map(Number);
if (v.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
s = dotted[1] + ((v[0] << 8) | v[1]).toString(16) + ':' + ((v[2] << 8) | v[3]).toString(16);
}
const halves = s.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : [];
let groups;
if (halves.length === 2) {
const fill = 8 - head.length - tail.length;
if (fill < 0) return null;
groups = head.concat(Array(fill).fill('0'), tail);
} else {
groups = head;
}
if (groups.length !== 8) return null;
const out = groups.map((g) => (g === '' ? NaN : parseInt(g, 16)));
if (out.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
return out;
}
function isBlockedV6(ip) {
const h = expandV6(ip);
if (!h) return true; // unparseable → block
// IPv4-mapped ::ffff:a.b.c.d and NAT64 64:ff9b::a.b.c.d → vet the embedded v4
if (h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0xffff) {
return isBlockedV4([(h[6] >> 8) & 255, h[6] & 255, (h[7] >> 8) & 255, h[7] & 255].join('.'));
}
if (h[0] === 0x0064 && h[1] === 0xff9b) {
return isBlockedV4([(h[6] >> 8) & 255, h[6] & 255, (h[7] >> 8) & 255, h[7] & 255].join('.'));
}
if (h.every((x) => x === 0)) return true; // :: unspecified
if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true; // ::1 loopback
if ((h[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local
if ((h[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
if ((h[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
if (h[0] === 0x2002) return true; // 2002::/16 6to4
return false;
}
// A resolved address we must never let the proxy connect to.
function isBlockedIp(ip) {
const v = net.isIP(ip);
if (v === 4) return isBlockedV4(ip);
if (v === 6) return isBlockedV6(ip);
return true; // not a valid literal IP → block
}
// Parse + scheme-check + DNS-resolve + vet EVERY resolved address. Returns { url, addresses } where
// `addresses` are the vetted IPs to pin the socket to. Throws SsrfError on anything unsafe.
async function assertSafeUrl(urlString) {
let url;
try { url = new URL(String(urlString)); } catch (e) { throw new SsrfError('bad-url'); }
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new SsrfError('bad-scheme');
if (url.username || url.password) throw new SsrfError('userinfo'); // http://internal@evil.com tricks
const host = url.hostname.replace(/^\[|\]$/g, '');
// A literal IP in the URL still gets vetted (no DNS, but same range checks).
if (net.isIP(host)) {
if (isBlockedIp(host)) throw new SsrfError('blocked-ip:' + host);
return { url, addresses: [host] };
}
let resolved;
try { resolved = await dns.lookup(host, { all: true, verbatim: true }); }
catch (e) { throw new SsrfError('dns-fail'); }
if (!resolved.length) throw new SsrfError('no-address');
for (const a of resolved) {
if (isBlockedIp(a.address)) throw new SsrfError('blocked-ip:' + a.address);
}
return { url, addresses: resolved.map((a) => a.address) };
}
// Build a `lookup` for http.request that pins to a pre-vetted address, so the socket connects to the
// IP we checked — not a value a rebinding DNS server hands back a second time.
function pinnedLookup(vettedAddresses) {
const addr = vettedAddresses[0];
const family = net.isIP(addr);
return (hostname, options, cb) => {
if (typeof options === 'function') { cb = options; }
process.nextTick(() => cb(null, addr, family));
};
}
module.exports = { assertSafeUrl, isBlockedIp, isBlockedV4, isBlockedV6, pinnedLookup, SsrfError };

View file

@ -0,0 +1,25 @@
'use strict';
// Builds the transition runtime the web player loads: params.js + renderer.js (both UMD -> set
// window.TransitionParams / window.TransitionRenderer in the browser) plus a shader-id -> source map.
// Assembled once from shared/Transitions so the player, Tizen, and CI can't drift from the .glsl files.
const fs = require('fs');
const path = require('path');
const DIR = path.join(__dirname, '../../shared/Transitions');
function build() {
const params = fs.readFileSync(path.join(DIR, 'params.js'), 'utf8');
const renderer = fs.readFileSync(path.join(DIR, 'renderer.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(DIR, 'manifest.json'), 'utf8'));
const shaders = {};
for (const e of manifest) shaders[e.id] = fs.readFileSync(path.join(DIR, e.file), 'utf8');
// __TRANSITION_SHADERS: id -> GLSL source (player + dashboard preview).
// __TRANSITION_MANIFEST: [{id,name,blurb,params}] for the dashboard picker (names + slider ranges).
return `${params}\n;\n${renderer}\n;\n`
+ `window.__TRANSITION_SHADERS=${JSON.stringify(shaders)};\n`
+ `window.__TRANSITION_MANIFEST=${JSON.stringify(manifest)};\n`;
}
let cached = null;
module.exports = {
bundle() { if (cached == null) cached = build(); return cached; },
};

View file

@ -0,0 +1,74 @@
'use strict';
// Normalize transition-widget config -> the opaque per-item `transition` object on published_snapshot.
//
// A transition is a WIDGET (widget_type='transition'), not a schema column. Its config lives in the
// widget's `config` JSON. Here we resolve it against the shader manifest + params.js (the single source
// of truth) and NEVER trust the stored blob: an unknown/removed shader yields no transition (the player
// hard-cuts), params are clamped to each shader's declared range, and duration is bounded. The result
// is attached to the visible item the transition plays INTO, and the transition widget itself is
// dropped from the snapshot so it never renders as content.
const path = require('path');
const MANIFEST = require(path.join(__dirname, '../../shared/Transitions/manifest.json'));
const { resolveParams } = require(path.join(__dirname, '../../shared/Transitions/params.js'));
const BY_ID = new Map(MANIFEST.map((m) => [m.id, m]));
const MIN_MS = 150, MAX_MS = 3000, DEFAULT_MS = 800;
// Parse + validate + clamp a transition config. A transition holds one OR MORE effects; the player
// picks one at random per advance (variety). Returns { effects:[{shader,params}], durationMs, scope }
// or null (unknown/empty -> no transition -> the player hard-cuts). Params live in a by-shader-id map;
// a single flat params object is also accepted (single-effect shape).
function resolveTransitionConfig(configJsonOrObj) {
let cfg;
if (typeof configJsonOrObj === 'string') { try { cfg = JSON.parse(configJsonOrObj); } catch (e) { return null; } }
else cfg = configJsonOrObj || {};
const ids = Array.isArray(cfg.shaders) ? cfg.shaders : (cfg.shader ? [cfg.shader] : []);
const paramsMap = (cfg.params && typeof cfg.params === 'object') ? cfg.params : {};
const effects = [];
const seen = new Set();
for (const id of ids) {
const entry = BY_ID.get(String(id));
if (!entry || seen.has(entry.id)) continue; // unknown/removed or dup -> skip
seen.add(entry.id);
// per-shader params from the map, else (single-effect shape) a flat params object, else defaults
const stored = paramsMap[entry.id] || (ids.length === 1 ? paramsMap : {});
effects.push({
shader: entry.id,
params: resolveParams(entry.params.map((p) => ({ name: p.name, default: p.default, min: p.min, max: p.max })), stored),
});
}
if (!effects.length) return null; // no valid effect -> hard cut, never a black frame
let durationMs = Number(cfg.durationMs);
if (!Number.isFinite(durationMs)) durationMs = DEFAULT_MS;
durationMs = Math.max(MIN_MS, Math.min(MAX_MS, Math.round(durationMs)));
const scope = cfg.scope === 'next' ? 'next' : 'all'; // default: one widget covers the whole playlist
return { effects, durationMs, scope };
}
// Walk snapshot items: drop transition-widget items, attach a resolved `transition` to the visible
// item each applies to (the one you transition INTO). scope:'all' sets a playlist-wide default;
// scope:'next' overrides the immediately-following visible item. A trailing scope:'next' with no
// following item wraps onto the first item (playlists loop, so last->first is a real advance).
function normalizeTransitions(items) {
const visible = [];
let pendingNext = null, playlistDefault = null;
for (const it of items) {
if (it && it.widget_type === 'transition') {
const cfg = resolveTransitionConfig(it.widget_config);
if (cfg) { if (cfg.scope === 'all') playlistDefault = cfg; else pendingNext = cfg; }
continue; // normalized out — never a visible item
}
visible.push(it);
it.__override = pendingNext;
pendingNext = null;
}
if (pendingNext && visible.length) visible[0].__override = visible[0].__override || pendingNext;
for (const it of visible) {
const t = it.__override || playlistDefault;
delete it.__override;
if (t) it.transition = { effects: t.effects, durationMs: t.durationMs };
}
return visible;
}
module.exports = { resolveTransitionConfig, normalizeTransitions, MANIFEST };

View file

@ -233,6 +233,9 @@
<script src="/socket.io/socket.io.js"></script>
<script src="/player/schedule-eval.js"></script>
<script src="/player/player-media-health.js"></script>
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
<script src="/player/transitions.js"></script>
<script>
// ==================== i18n ====================
// Lightweight inline i18n for the player. The player is a standalone page
@ -361,6 +364,7 @@
}
let playlist = [];
let currentIndex = -1;
let imgPreloadCache = {}; // src -> decoded HTMLImageElement, warmed one item ahead (feat/player-image-preload)
// #157: deferred rotation-out. When a playlist update removes the item currently on screen
// (e.g. it just expired) in solo playback, we keep it up and rotate to deferredSuccessorId on
// the next natural advance instead of interrupting/restarting.
@ -1623,10 +1627,12 @@
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too.
// STRUCTURAL fingerprint only (identity + order + schedules). duration_sec is deliberately
// EXCLUDED so a duration-only edit is not treated as a full change/restart — it's applied IN
// PLACE in the unchanged branch below (the schedule tick + advance timer read duration_sec live).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}`).join(',');
// transition-engine: include the per-item transition too, or a transition config change (effects,
// duration, scope, or presence) keeps the SAME fingerprint -> "unchanged" -> the player keeps a
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
@ -1728,6 +1734,7 @@
const oldAnchorId = identityOf(oldPlaylist[oldAnchorIdx]);
playlist = newItems;
imgPreloadCache = {}; // playlist changed — drop stale one-ahead preloads (feat/player-image-preload)
savePlaylistCache(playlist);
// #157: a fresh structural update supersedes any pending deferred rotation; the branches
// below re-arm it only if the current item was removed while live in solo playback.
@ -2150,10 +2157,308 @@
nextItem();
}
// feat/player-image-preload: warm the NEXT scheduled image (decoded) during the current dwell,
// and swap decode-gated so a slow panel never shows a blank/half-painted frame mid-decode.
function imgSrcFor(it) {
return it.remote_url || `${config.serverUrl}/uploads/content/${it.filepath}`;
}
// A remote image gets a proxy fallback (/media/proxy/:contentId) so it can be textured for a
// transition when its origin sends no CORS header. Local /uploads content is already CORS-enabled,
// and VIDEO never reaches here — images-only through the proxy, per the wiring rule.
function proxySrcFor(it) {
return (it && it.remote_url && it.content_id) ? `${config.serverUrl}/media/proxy/${it.content_id}` : null;
}
// Load an <img> for display + texturing. Try direct-with-CORS first (local + CORS-enabled remotes),
// then the proxy-with-CORS (non-CORS remotes -> same-origin + texturable), then a plain direct load
// so the frame still DISPLAYS as a last resort (a transition touching it just hard-cuts). onerror is
// wired before .src so a failure always advances the chain; onReady/onFail fire at most once.
function loadImageCors(src, proxySrc, onReady, onFail) {
const plan = [{ url: src, cors: true }];
if (proxySrc && proxySrc !== src) plan.push({ url: proxySrc, cors: true });
plan.push({ url: src, cors: false });
let i = 0;
const next = () => {
if (i >= plan.length) return onFail && onFail();
const step = plan[i++];
const img = new Image();
if (step.cors) img.crossOrigin = 'anonymous';
img.onload = () => onReady(img);
img.onerror = next;
try { img.src = step.url; } catch (e) { next(); }
};
next();
}
function cacheImg(src, img) {
imgPreloadCache[src] = img;
const keys = Object.keys(imgPreloadCache); // bound it: reschedule can leak entries
while (keys.length > 4) delete imgPreloadCache[keys.shift()];
}
function preloadNextImage() {
const nextIdx = nextActiveIndex(currentIndex);
if (nextIdx < 0 || nextIdx === currentIndex) return;
const it = playlist[nextIdx];
if (!it || typeof it.mime_type !== 'string' || !it.mime_type.startsWith('image/')) return;
const src = imgSrcFor(it);
if (imgPreloadCache[src]) return;
loadImageCors(src, proxySrcFor(it), (img) => {
(img.decode ? img.decode() : Promise.resolve()).then(() => cacheImg(src, img)).catch(() => cacheImg(src, img));
}, () => {}); // preload best-effort; a failed warm just means renderImageBuffered loads it live
}
// Plain hard-cut mount: tear down the outgoing frame, show the incoming image, arm the dwell.
function mountImage(img, item) {
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
teardownCurrentMedia();
const c = document.getElementById('playerContainer');
c.style.display = 'block';
c.appendChild(img);
advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
preloadNextImage();
}
// ---- GL transition (feat/transition-engine). Every failure path hard-cuts — never a blank. ----
function transitionRuntimeReady() {
return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS);
}
function shaderSource(id) { return (window.__TRANSITION_SHADERS && window.__TRANSITION_SHADERS[id]) || null; }
// 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
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
function currentTexturableFrame() {
const c = document.getElementById('playerContainer');
if (!c) return null;
const img = c.querySelector('img');
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
const v = c.querySelector('video');
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
try {
const r = c.getBoundingClientRect();
return fitToCanvas(v, Math.max(2, Math.round(r.width)), Math.max(2, Math.round(r.height)));
} catch (e) { return null; } // snapshot threw (taint slipped through) -> no from-frame -> hard cut
}
return null;
}
// draw a source (img | video | canvas) onto a container-sized canvas with object-fit:contain framing,
// so the transition matches the static letterboxing exactly (stays CORS-clean iff the source is). A
// <video> exposes its intrinsic size as videoWidth/Height, not naturalWidth/width — check both.
function fitToCanvas(src, w, h) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const cx = c.getContext('2d');
const iw = src.naturalWidth || src.videoWidth || src.width;
const ih = src.naturalHeight || src.videoHeight || src.height;
const s = Math.min(w / iw, h / ih);
cx.drawImage(src, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
return c;
}
// ONE persistent WebGL renderer, reused for EVERY transition. The canvas is attached to <body> ONCE
// and only SHOWN/HIDDEN per wipe — NEVER detached. Detaching a canvas can drop its WebGL context in
// some browsers (Firefox), which would reintroduce per-wipe context churn and kill every wipe after
// the first. Kept out of #playerContainer so teardownCurrentMedia() can't remove it. Recreated only
// on a genuine context loss.
let glTx = null, glTxAbort = null;
function getGlTransition() {
if (glTx && !glTx.renderer.lost) return glTx;
try {
const canvas = document.createElement('canvas');
canvas.id = 'txCanvas';
canvas.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;display:none;z-index:35;pointer-events:none';
const renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, {
onContextLost: () => { const a = glTxAbort; glTx = null; glTxAbort = null; if (a) a(); },
});
document.body.appendChild(canvas); // attach ONCE; stays forever
glTx = { canvas, renderer };
return glTx;
} catch (e) { glTx = null; return null; }
}
// Shared GL-wipe core for EVERY buffered transition — image OR video target. Renders `fromFrame`
// (img|video|canvas) -> `toTex` (the texturable incoming frame) on the persistent canvas. When the
// wipe completes (rAF reaches p>=1, the deadline fires, or the context is lost) it calls mount(),
// which inserts the REAL incoming element (img, or video+play) and owns its teardown, then hides the
// canvas AFTER the mount so there's no flash. onStart() runs once the wipe is committed (the image
// path arms its dwell there for overlap timing; video advances on 'ended' instead). Any setup
// failure / hidden tab / missing runtime calls hardCut() — never a blank frame. dwellMs bounds the
// wipe so it can't outlast an image's on-screen time.
function runGlWipe(fromFrame, toTex, t, dwellMs, onStart, mount, hardCut) {
const effect = t.effects[Math.floor(Math.random() * t.effects.length)]; // several effects -> pick one for variety
const src = effect && shaderSource(effect.shader);
if (!src) { hardCut(); return; }
if (document.hidden) { hardCut(); return; } // rAF frozen when hidden -> hard cut
const gl = getGlTransition();
if (!gl) { hardCut(); return; }
const canvas = gl.canvas, renderer = gl.renderer;
const container = document.getElementById('playerContainer');
const rect = container.getBoundingClientRect();
const w = Math.max(2, Math.round(rect.width)), h = Math.max(2, Math.round(rect.height));
let raf = 0, startTs = 0, done = false, deadline = 0;
const finish = () => { // end the wipe; mount the incoming element, KEEP the renderer/context alive
if (done) return; done = true;
if (raf) cancelAnimationFrame(raf);
if (deadline) clearTimeout(deadline);
if (glTxAbort === finish) glTxAbort = null;
mount(); // insert incoming + own its teardown/dwell/play
canvas.style.display = 'none'; // HIDE after the incoming frame is mounted (no flash); NEVER detach
};
try {
canvas.style.left = rect.left + 'px'; canvas.style.top = rect.top + 'px';
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
renderer.resize(w, h); // size the persistent canvas to the stage
renderer.setFrom(fitToCanvas(fromFrame, w, h));
renderer.setTo(fitToCanvas(toTex, w, h));
renderer.setShader(src); // throws on a bad shader
renderer.render(0, effect.params);
} catch (e) { canvas.style.display = 'none'; hardCut(); return; }
glTxAbort = finish; // context lost / tab hidden mid-wipe -> finish (hard cut)
canvas.style.display = 'block'; // SHOW over the current frame (fixed overlay)
container.style.display = 'block';
onStart(); // OVERLAP-not-additive: image dwell starts NOW (video: no-op)
const durMs = Math.min(t.durationMs, Math.max(150, dwellMs - 100)); // never exceed the dwell
// Safety net: also drive the final mount from a TIMER, not only the rAF loop. If rAF is throttled/
// frozen (backgrounded/occluded tab), the wipe won't animate but the content still swaps on time
// instead of the screen sticking. Whichever of {rAF hits p>=1, this deadline} fires wins.
deadline = setTimeout(finish, durMs + 80);
const frame = (ts) => {
if (done) return;
if (renderer.lost) return finish();
if (!startTs) startTs = ts;
const p = Math.min(1, (ts - startTs) / durMs);
if (!renderer.render(p, effect.params)) return finish();
if (p >= 1) return finish();
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
}
// Image target: wipe fromImg -> toImg, then mount the plain <img>. Dwell is armed at wipe START
// (overlap timing) so the transition plays INSIDE the item's duration; finish just swaps the frame.
function runImageTransition(fromImg, toImg, t, item) {
const dwellMs = (item.duration_sec || 10) * 1000;
const container = document.getElementById('playerContainer');
runGlWipe(fromImg, toImg, t, dwellMs,
() => { advanceTimer = setTimeout(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
() => { // mount: swap in the image, keep the armed timer
toImg.style.cssText = 'width:100%;height:100%;object-fit:contain';
container.appendChild(toImg);
teardownCurrentMedia(toImg); // drop outgoing, KEEP toImg + the armed dwell timer
preloadNextImage();
},
() => { mountImage(toImg, item); }); // hardCut: plain mount (arms its own dwell)
}
function renderImageBuffered(item) {
const src = imgSrcFor(item);
const cached = imgPreloadCache[src];
let done = false, watchdog = null;
const swap = (img) => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
delete imgPreloadCache[src];
if (glTxAbort) glTxAbort(); // settle any in-flight wipe first — one transition at a time on the shared renderer
const from = currentTexturableFrame(); // may be an outgoing <video> snapshot -> video→image wipes
const t = item.transition;
const canTexture = img.complete && img.naturalWidth > 0 && isMediaReadable(img);
if (t && Array.isArray(t.effects) && t.effects.length && from && canTexture && transitionRuntimeReady()) {
runImageTransition(from, img, t, item); // owns teardown + advance + preload
} else {
mountImage(img, item); // hard cut (never blank)
}
};
const fail = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); // skip broken item; hold prior frame
};
// a hung load/decode must never stall the playlist: at 3s use what we have, else skip
watchdog = setTimeout(() => { if (cached) swap(cached); else fail(); }, 3000);
if (cached) { swap(cached); return; } // decoded ahead — instant, CORS-clean
loadImageCors(src, proxySrcFor(item), (img) => { // CORS -> proxy -> plain, then decode-gate
(img.decode ? img.decode() : Promise.resolve()).then(() => swap(img)).catch(() => swap(img));
}, fail);
}
// Buffered SOLO-video render (feat/transition-engine): wipe the outgoing frame INTO a video —
// image→video and video→video. Only fullscreen solo videos reach here (the renderContent gate
// excludes wall/zone/group/widget/youtube), so none of the wall/follower/sync logic applies.
//
// A just-'loadeddata' but never-played <video> does NOT reliably paint via drawImage (some engines
// present no frame until playback starts) — so we warm-play the incoming clip MUTED offscreen until
// its first frame is actually PRESENTED (requestVideoFrameCallback), pause it there, snapshot that
// 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.
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 video = document.createElement('video');
video.crossOrigin = 'anonymous'; // texturable (CORS-clean) + matches the legacy branch
video.playsInline = true;
video.preload = 'auto';
video.muted = true; // warm-play MUST be muted (autoplay policy); real mute set at mount
video.loop = (playlist.length === 1); // single-item playlist holds by looping
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000';
let done = false, watchdog = null;
// Full teardown + append + resume. The incoming <video> is detached until now, so teardown's
// container-wide video cleanup can't touch it; we claim currentVideoEl only AFTER teardown (which
// 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 = () => {
const c = document.getElementById('playerContainer');
teardownCurrentMedia(); // drop the outgoing frame (new video still detached)
c.style.display = 'block';
c.appendChild(video);
currentVideoEl = video;
video.muted = (!userHasInteracted || !!item.muted); // autoplay policy + per-item mute (#129)
if (!video.muted) video.volume = 1.0;
video.onended = () => { if (!video.loop) nextItem(); };
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); };
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); // autoplay-policy fallback
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000); // last-resort kick
};
// First PRESENTED frame reached (or watchdog): freeze it, snapshot, wipe if we have from+to+runtime.
const onFirstFrame = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
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;
if (canTexture) {
try {
const r = document.getElementById('playerContainer').getBoundingClientRect();
to = fitToCanvas(video, Math.max(2, Math.round(r.width)), Math.max(2, Math.round(r.height)));
} catch (e) { to = null; } // snapshot tainted/threw -> hard cut below
}
if (from && to && t && Array.isArray(t.effects) && t.effects.length && transitionRuntimeReady()) {
runGlWipe(from, to, t, t.durationMs + 200, // video has no image-dwell; bound only keeps the full duration
() => {}, // onStart: video advance is driven by 'ended', not a dwell timer
mountVideo, mountVideo); // mount AND hardCut both mount+resume the real video
} else {
mountVideo(); // no from-frame / un-texturable / no runtime -> hard cut
}
};
// Warm-play until the first frame is PRESENTED. rVFC is the precise "a frame just painted" signal;
// without it, fall back to a short beat after playback starts. If play() is somehow rejected we still
// arm the frame wait (the watchdog is the final backstop).
const armFrame = () => {
if ('requestVideoFrameCallback' in video) video.requestVideoFrameCallback(() => onFirstFrame());
else setTimeout(onFirstFrame, 150);
};
video.addEventListener('loadeddata', () => { video.play().then(armFrame).catch(armFrame); }, { once: true });
// A decode failure or a hung load must never stall the playlist.
video.addEventListener('error', () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Video error:', src); advanceTimer = setTimeout(nextItem, 3000); // skip broken clip; hold prior frame
});
// 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);
video.src = src;
video.load();
}
function renderContent(item) {
// 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; }
// Defense in depth: a transition widget is normalized out server-side and must never render as
// content. If a stale/legacy payload still carries one, skip it instead of mounting a blank iframe.
if (item && item.widget_type === 'transition') { advanceTimer = setTimeout(nextItem, 0); return; }
// Fullscreen (non-wall) widget: buffered swap — never blank on reload. Runs BEFORE the
// generic teardown (which would black the screen), and owns its own refresh/advance
// timer below. Multi-zone widgets go through renderZones; wall+widget keeps the legacy
@ -2176,6 +2481,30 @@
return;
}
// feat/player-image-preload: decode-gated buffered swap for the common fullscreen image case.
// Runs BEFORE the generic teardown so the outgoing frame survives until the incoming image is
// DECODED (no blank/half-painted flash on slow panels). Wall/group/zone images fall through to
// the legacy path below (they carry their own sync/preload handling).
const isImageBufferable = item && typeof item.mime_type === 'string' && item.mime_type.startsWith('image/')
&& !item.widget_id && !wallConfig && !isZones && !groupSync;
if (isImageBufferable) {
renderImageBuffered(item);
return;
}
// Same buffered path for a SOLO video that has a transition: wipe the outgoing frame into it
// (image→video / video→video). Only when a runtime + transition are actually present — a plain
// solo video falls through to the legacy branch, which keeps its preload/mute/loop handling. Wall/
// zone/group/widget are excluded (they carry their own sync); YouTube (video/youtube) too.
const isVideoBufferable = item && typeof item.mime_type === 'string'
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
&& !item.widget_id && !wallConfig && !isZones && !groupSync
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
&& transitionRuntimeReady();
if (isVideoBufferable) {
renderVideoBuffered(item);
return;
}
teardownCurrentMedia();
const container = document.getElementById('playerContainer');
@ -2722,6 +3051,7 @@
// liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously
// tear down a live socket on the hidden->visible gap.
if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); }
else if (glTxAbort) { glTxAbort(); } // tab hidden mid-wipe: hard-cut NOW (before rAF freezes) so nothing sticks
});
// pairing-race fix: pageshow fires on EVERY load (persisted=false), not only bfcache restores.
// Unfiltered it ran verifyLivenessSoon() on the initial cold load, which opened the socket EARLY

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'rd-player-v11';
const CACHE_NAME = 'rd-player-v17';
// Install: skip waiting to activate immediately
self.addEventListener('install', (event) => {

267
server/routes/media.js Normal file
View file

@ -0,0 +1,267 @@
'use strict';
// Media proxy — GET /media/proxy/:itemId
//
// Threat model (see also server/lib/ssrf-guard.js):
// * NOT an open proxy. The caller never supplies a URL — we look the item up server-side and fetch
// its stored content.remote_url. The only fetchable targets are URLs a customer already put in a
// playlist, so bandwidth-theft / abuse-laundering (using our box to fetch arbitrary public URLs)
// is eliminated by construction. The SSRF guard then handles the residual case: a customer who
// sets a hostile remote_url pointing at our internal network.
// * We serve upstream bytes SAME-ORIGIN (so a WebGL transition can read them), which means anything
// we serve executes in the ScreenTinker origin. Content-Type from upstream is a lie we never trust:
// we sniff magic bytes, refuse anything that isn't a real image/video, serve the SNIFFED type with
// X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox. That kills text/html -> XSS.
// * Size is capped on the STREAM (bytes actually received), not Content-Length (absent or lying);
// we abort mid-transfer at the ceiling. The socket is pinned to the vetted IP (anti-rebinding) with
// SNI/cert validation still against the original hostname. Redirects are re-vetted every hop.
// * Single-flight per cache key: 50 panels advancing to the same cold asset => 1 upstream fetch.
const express = require('express');
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { db } = require('../db/database');
const config = require('../config');
const { assertSafeUrl, pinnedLookup, SsrfError } = require('../lib/ssrf-guard');
const router = express.Router();
const MAX_BYTES = parseInt(process.env.MEDIA_PROXY_MAX_BYTES, 10) || 128 * 1024 * 1024; // per-item ceiling; abort the stream past this
const MAX_REDIRECTS = 4;
const IDLE_TIMEOUT_MS = 20000; // no-progress socket timeout
const SNIFF_BYTES = 32; // enough for every magic below
const CACHE_DIR = path.join(config.dataDir, 'proxy-cache'); // NOT under contentDir (never statically served)
const CACHE_CAP_BYTES = parseInt(process.env.MEDIA_PROXY_CACHE_CAP_BYTES, 10) || 2 * 1024 * 1024 * 1024; // TOTAL cache cap
const TTL_MS = parseInt(process.env.MEDIA_PROXY_TTL_MS, 10) || 5 * 60 * 1000; // revalidate upstream past this age
// A full disk on a signage box is a cross-tenant outage — refuse to write when free space is below this.
function freeFloorBytes() { return parseInt(process.env.MEDIA_PROXY_FREE_FLOOR_BYTES, 10) || 1024 * 1024 * 1024; }
function freeSpace() { try { const s = fs.statfsSync(CACHE_DIR); return s.bavail * s.bsize; } catch (e) { return Infinity; } }
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) { /* fall through; writes will surface errors */ }
// A media-layer failure that is the upstream's fault (not an SSRF block).
class MediaError extends Error { constructor(msg) { super(msg); this.name = 'MediaError'; } }
const dataFile = (key) => path.join(CACHE_DIR, key + '.bin');
const metaFile = (key) => path.join(CACHE_DIR, key + '.json');
// ---- magic-byte sniff: return a real image/video mime, or null (=> reject) ----
function sniffMedia(buf) {
if (buf.length < 2) return null;
const b = buf;
const a4 = b.length >= 4 ? b.toString('latin1', 0, 4) : '';
// images
if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return 'image/jpeg';
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return 'image/png';
if (a4 === 'GIF8') return 'image/gif';
if (b[0] === 0x42 && b[1] === 0x4D) return 'image/bmp';
if (a4 === 'RIFF' && b.length >= 12 && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
// ISO-BMFF (mp4/avif/heic): '....ftyp<brand>'
if (b.length >= 12 && b.toString('latin1', 4, 8) === 'ftyp') {
const brand = b.toString('latin1', 8, 12);
if (brand === 'avif' || brand === 'avis') return 'image/avif';
if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic';
return 'video/mp4'; // isom, mp42, mp41, dash, etc.
}
// videos
if (b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3) return 'video/webm'; // EBML (webm/mkv)
if (a4 === 'OggS') return 'video/ogg';
return null;
}
const ALLOWED_PREFIX = /^(image|video)\//;
// ---- outbound fetch: vet URL, pin socket to vetted IP, re-vet each redirect hop.
// Resolves { res } for a 200 stream, or { notModified:true } for a 304 (conditional revalidation).
function resolveWithRedirects(rawUrl, redirectsLeft, validators) {
return new Promise((resolve, reject) => {
assertSafeUrl(rawUrl).then(({ url, addresses }) => {
const mod = url.protocol === 'https:' ? https : http;
const headers = { 'user-agent': 'ScreenTinker-media-proxy', accept: 'image/*,video/*' };
if (validators && validators.etag) headers['if-none-match'] = validators.etag;
if (validators && validators.lastModified) headers['if-modified-since'] = validators.lastModified;
const req = mod.request(url, {
method: 'GET',
lookup: pinnedLookup(addresses), // connect to the vetted IP only (defeats DNS rebinding)
servername: url.hostname, // SNI + cert validation stay against the hostname, not the IP
headers,
}, (res) => {
const sc = res.statusCode;
if (sc === 304) { res.resume(); return resolve({ notModified: true }); } // still current upstream
if (sc >= 300 && sc < 400 && res.headers.location) {
res.resume(); // drain the redirect body
if (redirectsLeft <= 0) return reject(new MediaError('too-many-redirects'));
let next;
try { next = new URL(res.headers.location, url).toString(); }
catch (e) { return reject(new MediaError('bad-redirect')); }
return resolveWithRedirects(next, redirectsLeft - 1, validators).then(resolve, reject);
}
if (sc !== 200) { res.resume(); return reject(new MediaError('upstream-status-' + sc)); }
resolve({ res });
});
req.setTimeout(IDLE_TIMEOUT_MS, () => req.destroy(new MediaError('timeout')));
req.on('error', reject);
req.end();
}, reject);
});
}
// ---- consume a vetted 200 stream into the disk cache: sniff on first bytes, cap on the stream ----
function consumeToCache(res, key) {
return new Promise((resolve, reject) => {
{
// Free-space floor: try to reclaim from our own cache first, then refuse rather than fill the disk.
if (freeSpace() < freeFloorBytes()) {
evictIfOverCap();
if (freeSpace() < freeFloorBytes()) { res.destroy(); return reject(new MediaError('disk-full')); }
}
const tmp = dataFile(key) + '.tmp-' + crypto.randomBytes(6).toString('hex');
const out = fs.createWriteStream(tmp);
let received = 0, head = [], headLen = 0, sniffed = null, aborted = false;
const abort = (err) => {
if (aborted) return; aborted = true;
res.destroy(); out.destroy();
fs.unlink(tmp, () => {});
reject(err);
};
out.on('error', abort);
res.on('error', abort);
const trySniff = (final) => {
if (sniffed || (!final && headLen < SNIFF_BYTES)) return true;
sniffed = sniffMedia(Buffer.concat(head));
if (!sniffed || !ALLOWED_PREFIX.test(sniffed)) { abort(new MediaError('unsupported-content')); return false; }
head = null; // release
return true;
};
res.on('data', (chunk) => {
if (aborted) return;
received += chunk.length;
if (received > MAX_BYTES) return abort(new MediaError('too-large'));
if (!sniffed) { head.push(chunk); headLen += chunk.length; if (!trySniff(false)) return; }
if (!out.write(chunk)) { res.pause(); out.once('drain', () => res.resume()); }
});
res.on('end', () => {
if (aborted) return;
if (!sniffed && !trySniff(true)) return; // tiny file: sniff whatever we got
out.end(() => {
try {
fs.renameSync(tmp, dataFile(key));
const h = res.headers || {};
const meta = { type: sniffed, size: received, fetchedAt: Date.now(),
etag: h.etag || null, lastModified: h['last-modified'] || null };
fs.writeFileSync(metaFile(key), JSON.stringify(meta));
evictIfOverCap();
resolve(meta);
} catch (e) { fs.unlink(tmp, () => {}); reject(e); }
});
});
}
});
}
function readMeta(key) {
try {
const m = JSON.parse(fs.readFileSync(metaFile(key), 'utf8'));
if (m && ALLOWED_PREFIX.test(m.type) && fs.existsSync(dataFile(key))) return m;
} catch (e) { /* miss */ }
return null;
}
function isFresh(meta) { return meta && typeof meta.fetchedAt === 'number' && (Date.now() - meta.fetchedAt) < TTL_MS; }
// upstream unchanged (304) or a revalidation error: keep the bytes, just reset freshness + LRU stamp
function touchMeta(key, meta) {
meta.fetchedAt = Date.now();
try { fs.writeFileSync(metaFile(key), JSON.stringify(meta)); const t = new Date(); fs.utimesSync(dataFile(key), t, t); } catch (e) {}
return meta;
}
// Miss -> download. Stale hit -> conditional GET: 304 keeps the bytes, 200 replaces them, and a
// revalidation FAILURE serves the stale copy (a signage screen must never blank on a flaky upstream).
function fetchOrRevalidate(remoteUrl, key, existing) {
const validators = existing && (existing.etag || existing.lastModified)
? { etag: existing.etag, lastModified: existing.lastModified } : null;
return resolveWithRedirects(remoteUrl, MAX_REDIRECTS, validators).then((r) => {
if (r.notModified && existing) return touchMeta(key, existing);
return consumeToCache(r.res, key);
}).catch((err) => {
if (existing) return touchMeta(key, existing); // stale-while-error
throw err;
});
}
const inflight = new Map(); // cache key -> Promise<meta> (single-flight; covers revalidation too)
function ensureCached(remoteUrl, key, fetcher) {
const hit = readMeta(key);
if (hit && isFresh(hit)) return Promise.resolve(hit);
if (inflight.has(key)) return inflight.get(key);
const run = fetcher || ((u, k) => fetchOrRevalidate(u, k, hit)); // hit may be a stale copy to revalidate
const p = run(remoteUrl, key, hit).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// bounded cache: evict oldest (by write time) until under the byte cap
function evictIfOverCap() {
try {
const bins = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith('.bin'));
const entries = bins.map((f) => {
const p = path.join(CACHE_DIR, f);
const s = fs.statSync(p);
return { p, base: f.slice(0, -4), size: s.size, mtime: s.mtimeMs };
});
let total = entries.reduce((a, e) => a + e.size, 0);
if (total <= CACHE_CAP_BYTES) return;
entries.sort((a, b) => a.mtime - b.mtime);
for (const e of entries) {
if (total <= CACHE_CAP_BYTES) break;
try { fs.unlinkSync(e.p); } catch (x) {}
fs.unlink(path.join(CACHE_DIR, e.base + '.json'), () => {});
total -= e.size;
}
} catch (e) { /* best-effort */ }
}
function serveFromCache(res, key, meta) {
res.setHeader('Content-Type', meta.type); // the SNIFFED type, never upstream's claim
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', 'sandbox'); // if ever navigated to directly, no script/plugins
res.setHeader('Access-Control-Allow-Origin', '*'); // canvas/WebGL needs to read the pixels
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// short client cache so a stale screen self-corrects within ~a minute; server-side TTL handles upstream
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Content-Length', meta.size);
const t = new Date(); fs.utimes(dataFile(key), t, t, () => {}); // LRU: stamp access time for eviction ordering
const rs = fs.createReadStream(dataFile(key));
rs.on('error', () => { if (!res.headersSent) res.status(500).end(); else res.destroy(); });
rs.pipe(res);
}
// Keyed on content.id (a TEXT id present in the id-free published_snapshot the player consumes; the
// playlist_items id is not). content OWNS remote_url, so this is the natural lookup and lets multiple
// playlist items that reference the same image share one cache entry. Still not an open proxy: only a
// content row a customer created is fetchable, and the SSRF guard gates the actual remote_url fetch.
router.get('/proxy/:contentId', (req, res) => {
const contentId = String(req.params.contentId || '');
if (!contentId || contentId.length > 64 || !/^[A-Za-z0-9_-]+$/.test(contentId)) {
return res.status(400).end();
}
let row;
try {
row = db.prepare('SELECT remote_url FROM content WHERE id = ?').get(contentId);
} catch (e) { return res.status(500).end(); }
if (!row || !row.remote_url) return res.status(404).end();
const key = crypto.createHash('sha256').update(String(row.remote_url)).digest('hex');
ensureCached(row.remote_url, key)
.then((meta) => serveFromCache(res, key, meta))
.catch((err) => {
if (res.headersSent) return res.destroy();
const code = err instanceof SsrfError ? 403 : (err instanceof MediaError ? 502 : 500);
res.status(code).end();
});
});
module.exports = router;
// exported for tests (security-critical internals, exercised without the DB/express layer)
module.exports.__test = { sniffMedia, resolveWithRedirects, consumeToCache, fetchOrRevalidate, ensureCached, readMeta, isFresh, touchMeta, evictIfOverCap, inflight, dataFile, metaFile, CACHE_DIR, MAX_BYTES, TTL_MS };

View file

@ -301,6 +301,14 @@ app.get('/player/player-media-health.js', (req, res) => {
res.sendFile(path.join(__dirname, 'lib', 'player-media-health.js'));
});
// Transition runtime bundle (renderer.js + params.js + shader sources) built from shared/Transitions.
// If this ever fails to load, the player simply hard-cuts (never blank) — it's a progressive enhancement.
app.get('/player/transitions.js', (req, res) => {
res.type('application/javascript').setHeader('Cache-Control', 'public, max-age=300');
try { res.send(require('./lib/transition-bundle').bundle()); }
catch (e) { res.status(500).send('/* transition bundle unavailable */'); }
});
// Serve web player at /player (same no-cache for JS/HTML). The index.html
// route above intercepts the HTML requests; everything else still falls
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
@ -737,6 +745,12 @@ app.use('/uploads/content', (req, res, next) => {
next();
}, express.static(config.contentDir));
// Media proxy for remote (URL-referenced) playlist items — public by construction (players are
// unauthenticated browsers). Takes an itemId, never a caller URL: it fetches the item's stored
// remote_url through the SSRF guard so a WebGL transition can read the bytes same-origin. Must sit
// before the SPA catch-all (app.get('*')) or that would swallow /media/proxy/*.
app.use('/media', require('./routes/media'));
// Setup WebSockets
const setupWebSockets = require('./ws');
const { deviceNs, dashboardNs } = setupWebSockets(io);

View file

@ -0,0 +1,196 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const { Readable } = require('stream');
// Isolate state BEFORE requiring the app: temp DATA_DIR (throwaway DB + cache) and a tiny size cap.
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-media-'));
process.env.DATA_DIR = TMP;
process.env.MEDIA_PROXY_MAX_BYTES = '1024';
const express = require('express');
const media = require('../routes/media');
const { db } = require('../db/database');
const { sniffMedia, consumeToCache, ensureCached, fetchOrRevalidate, isFresh, dataFile, metaFile, readMeta } = media.__test;
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(40)]);
const jpeg = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(40)]);
const gif = Buffer.concat([Buffer.from('GIF89a'), Buffer.alloc(40)]);
const webp = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(40)]);
const mp4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypisom'), Buffer.alloc(40)]);
const avif = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypavif'), Buffer.alloc(40)]);
const webm = Buffer.concat([Buffer.from([0x1A, 0x45, 0xDF, 0xA3]), Buffer.alloc(40)]);
const ogg = Buffer.concat([Buffer.from('OggS'), Buffer.alloc(40)]);
const html = Buffer.from('<html><head><title>x</title></head><body>hi</body></html>');
const doctype = Buffer.from('<!DOCTYPE html><html></html> ');
const pdf = Buffer.concat([Buffer.from('%PDF-1.7'), Buffer.alloc(40)]);
test('sniffMedia: real media accepted, everything else rejected (XSS defense)', () => {
assert.equal(sniffMedia(png), 'image/png');
assert.equal(sniffMedia(jpeg), 'image/jpeg');
assert.equal(sniffMedia(gif), 'image/gif');
assert.equal(sniffMedia(webp), 'image/webp');
assert.equal(sniffMedia(mp4), 'video/mp4');
assert.equal(sniffMedia(avif), 'image/avif');
assert.equal(sniffMedia(webm), 'video/webm');
assert.equal(sniffMedia(ogg), 'video/ogg');
assert.equal(sniffMedia(html), null); // the whole point: HTML never passes
assert.equal(sniffMedia(doctype), null);
assert.equal(sniffMedia(pdf), null);
assert.equal(sniffMedia(Buffer.alloc(0)), null);
assert.equal(sniffMedia(Buffer.from([0x89])), null);
});
test('consumeToCache: valid image is cached with sniffed type', async () => {
const key = 'okpng';
const meta = await consumeToCache(Readable.from([png]), key);
assert.equal(meta.type, 'image/png');
assert.equal(meta.size, png.length);
assert.ok(fs.existsSync(dataFile(key)));
assert.deepEqual(readMeta(key), meta);
});
test('consumeToCache: HTML body rejected even under the size cap, nothing persisted', async () => {
const key = 'badhtml';
await assert.rejects(consumeToCache(Readable.from([Buffer.concat([html, Buffer.alloc(40)])]), key),
(e) => /unsupported-content/.test(e.message));
assert.ok(!fs.existsSync(dataFile(key)), 'no cache file for rejected content');
});
test('consumeToCache: stream over the byte cap is aborted (Content-Length not trusted)', async () => {
const key = 'toobig';
await assert.rejects(consumeToCache(Readable.from([Buffer.alloc(2048)]), key), // > 1024 cap
(e) => /too-large/.test(e.message));
assert.ok(!fs.existsSync(dataFile(key)));
});
test('ensureCached: single-flight collapses concurrent misses to one fetch', async () => {
let calls = 0;
const fetcher = (url, key) => { calls++; return new Promise((r) => setImmediate(() => r({ type: 'image/png', size: 1 }))); };
const key = 'sf-' + crypto.randomBytes(3).toString('hex');
const [a, b] = await Promise.all([
ensureCached('http://x/1', key, fetcher),
ensureCached('http://x/1', key, fetcher),
]);
assert.equal(calls, 1, 'fifty panels -> one upstream fetch');
assert.deepEqual(a, b);
});
test('route: 400 on malformed content id, 404 on unknown content', async () => {
const { port, close } = await listen();
try {
assert.equal((await get(port, '/media/proxy/bad.id')).status, 400, 'dot is not a valid id char');
assert.equal((await get(port, '/media/proxy/ghostcontent')).status, 404, 'well-formed but no such content');
} finally { await close(); }
});
test('route: cache hit serves sniffed type with hardening headers', async () => {
db.pragma('foreign_keys = OFF');
db.prepare("INSERT INTO content (id, filename, mime_type, remote_url) VALUES ('c1','x.png','image/png','http://example.test/x')").run();
const key = crypto.createHash('sha256').update('http://example.test/x').digest('hex');
fs.writeFileSync(dataFile(key), png);
fs.writeFileSync(metaFile(key), JSON.stringify({ type: 'image/png', size: png.length, fetchedAt: Date.now() }));
const { port, close } = await listen();
try {
const r = await get(port, '/media/proxy/c1');
assert.equal(r.status, 200);
assert.equal(r.headers['content-type'], 'image/png');
assert.equal(r.headers['x-content-type-options'], 'nosniff');
assert.equal(r.headers['content-security-policy'], 'sandbox');
assert.equal(r.headers['access-control-allow-origin'], '*');
assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin');
assert.equal(r.body.length, png.length);
} finally { await close(); }
});
test('route: hostile remote_url pointing at loopback is blocked end-to-end (403)', async () => {
db.pragma('foreign_keys = OFF');
db.prepare("INSERT INTO content (id, filename, mime_type, remote_url) VALUES ('c2','y.png','image/png','http://127.0.0.1:9/x')").run();
const { port, close } = await listen();
try {
const r = await get(port, '/media/proxy/c2');
assert.equal(r.status, 403, 'SSRF guard refuses the live fetch to a private IP');
} finally { await close(); }
});
test('consumeToCache: captures ETag/Last-Modified + fetchedAt for revalidation', async () => {
const key = 'validators';
const s = Readable.from([png]); s.headers = { etag: '"abc123"', 'last-modified': 'Wed, 21 Oct 2025 07:28:00 GMT' };
const meta = await consumeToCache(s, key);
assert.equal(meta.etag, '"abc123"');
assert.equal(meta.lastModified, 'Wed, 21 Oct 2025 07:28:00 GMT');
assert.equal(typeof meta.fetchedAt, 'number');
});
test('ensureCached: fresh hit served without refetch, stale hit triggers revalidation', async () => {
const key = 'freshness';
fs.writeFileSync(dataFile(key), png);
let called = 0;
const fetcher = () => { called++; return Promise.resolve({ type: 'image/png', size: png.length, fetchedAt: Date.now() }); };
fs.writeFileSync(metaFile(key), JSON.stringify({ type: 'image/png', size: png.length, fetchedAt: Date.now() }));
assert.equal(isFresh(readMeta(key)), true);
await ensureCached('http://x/a', key, fetcher);
assert.equal(called, 0, 'fresh hit does not refetch');
fs.writeFileSync(metaFile(key), JSON.stringify({ type: 'image/png', size: png.length, fetchedAt: 1 })); // ancient
assert.equal(isFresh(readMeta(key)), false);
await ensureCached('http://x/a', key, fetcher);
assert.equal(called, 1, 'stale hit revalidates');
});
test('fetchOrRevalidate: revalidation failure serves the STALE copy (never blank)', async () => {
const key = 'stale-serve';
fs.writeFileSync(dataFile(key), png);
const existing = { type: 'image/png', size: png.length, fetchedAt: 1, etag: '"x"' };
fs.writeFileSync(metaFile(key), JSON.stringify(existing));
// loopback remote_url -> SSRF guard rejects the revalidation fetch -> must fall back to stale bytes
const meta = await fetchOrRevalidate('http://127.0.0.1:9/x', key, existing);
assert.equal(meta.type, 'image/png');
assert.ok(meta.fetchedAt > 1, 'freshness stamp bumped so we do not hammer a down upstream');
});
test('fetchOrRevalidate: hard miss to a blocked host throws (no stale to fall back on)', async () => {
await assert.rejects(fetchOrRevalidate('http://127.0.0.1:9/x', 'nomiss', null));
});
test('consumeToCache: refuses to write when free space is below the floor (disk-fill guard)', async () => {
const key = 'diskfull';
const prev = process.env.MEDIA_PROXY_FREE_FLOOR_BYTES;
process.env.MEDIA_PROXY_FREE_FLOOR_BYTES = String(Number.MAX_SAFE_INTEGER); // floor above any real free space
try {
await assert.rejects(consumeToCache(Readable.from([png]), key), (e) => /disk-full/.test(e.message));
assert.ok(!fs.existsSync(dataFile(key)));
} finally {
if (prev === undefined) delete process.env.MEDIA_PROXY_FREE_FLOOR_BYTES; else process.env.MEDIA_PROXY_FREE_FLOOR_BYTES = prev;
}
});
// ---- helpers ----
function listen() {
const app = express();
app.use('/media', media);
return new Promise((resolve) => {
const srv = app.listen(0, '127.0.0.1', () => resolve({
port: srv.address().port,
close: () => new Promise((r) => srv.close(r)),
}));
});
}
function get(port, p) {
return new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port, path: p }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
}).on('error', reject);
});
}
test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {} });

View file

@ -0,0 +1,39 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { assertSafeUrl, SsrfError } = require('../lib/ssrf-guard');
const BLOCK = [
['bad scheme', 'ftp://example.com/x.png'],
['userinfo trick', 'http://internal@8.8.8.8/x.png'],
['loopback literal', 'http://127.0.0.1/x.png'],
['loopback:port', 'http://127.0.0.1:3001/x.png'],
['0.0.0.0', 'http://0.0.0.0/x'],
['private 10/8', 'http://10.1.2.3/x'],
['private 192.168', 'http://192.168.0.1/x'],
['private 172.16', 'http://172.16.5.5/x'],
['CGNAT 100.64', 'http://100.64.0.1/x'],
['cloud metadata 169.254.169.254', 'http://169.254.169.254/latest/meta-data/'],
['v6 loopback', 'http://[::1]/x'],
['v6 ULA fc00', 'http://[fc00::1]/x'],
['v6 link-local fe80', 'http://[fe80::1]/x'],
['v4-mapped loopback', 'http://[::ffff:127.0.0.1]/x'],
['localhost (DNS->127.0.0.1)', 'http://localhost:3001/x'],
];
const ALLOW = [
['public v4 8.8.8.8', 'http://8.8.8.8/x.png'],
['public v4 1.1.1.1 https', 'https://1.1.1.1/x.png'],
['public v6', 'http://[2606:4700:4700::1111]/x'],
];
for (const [name, url] of BLOCK) {
test('BLOCK ' + name, async () => {
await assert.rejects(() => assertSafeUrl(url), (e) => e instanceof SsrfError, 'expected SsrfError for ' + url);
});
}
for (const [name, url] of ALLOW) {
test('ALLOW ' + name, async () => {
const r = await assertSafeUrl(url);
assert.ok(r.addresses.length > 0, 'expected vetted addresses for ' + url);
});
}

View file

@ -0,0 +1,88 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { resolveTransitionConfig, normalizeTransitions } = require('../lib/transition-config');
const T = (cfg) => ({ widget_id: 'w', widget_type: 'transition', widget_config: JSON.stringify(cfg) });
const IMG = (id) => ({ content_id: id, mime_type: 'image/png' });
test('resolveTransitionConfig: valid shader resolves + clamps params/duration', () => {
const r = resolveTransitionConfig({ shader: 'CRTCollapse', params: { lineHold: 99, flashGain: -5 }, durationMs: 800, scope: 'next' });
assert.equal(r.effects.length, 1);
assert.equal(r.effects[0].shader, 'CRTCollapse');
assert.equal(r.durationMs, 800);
assert.equal(r.scope, 'next');
assert.equal(r.effects[0].params.lineHold, 0.45, 'over-range param clamped to shader max');
assert.equal(r.effects[0].params.flashGain, 0, 'under-range param clamped to shader min');
});
test('resolveTransitionConfig: multiple shaders -> one effect each, per-shader params via map', () => {
const r = resolveTransitionConfig({ shaders: ['CRTCollapse', 'Etch'], params: { CRTCollapse: { lineHold: 0.2 } }, durationMs: 500 });
assert.deepEqual(r.effects.map((e) => e.shader), ['CRTCollapse', 'Etch']);
assert.equal(r.effects[0].params.lineHold, 0.2, 'per-shader params applied from the map');
});
test('resolveTransitionConfig: unknown ids dropped from the set, dups collapsed, all-unknown -> null', () => {
const r = resolveTransitionConfig({ shaders: ['CRTCollapse', 'Ghost', 'CRTCollapse', 'Etch'] });
assert.deepEqual(r.effects.map((e) => e.shader), ['CRTCollapse', 'Etch']);
assert.equal(resolveTransitionConfig({ shaders: [] }), null);
assert.equal(resolveTransitionConfig({ shaders: ['Ghost'] }), null);
});
test('resolveTransitionConfig: unknown shader -> null (hard cut, never black)', () => {
assert.equal(resolveTransitionConfig({ shader: 'NopeShader' }), null);
assert.equal(resolveTransitionConfig({ shader: '' }), null);
assert.equal(resolveTransitionConfig('not json'), null);
});
test('resolveTransitionConfig: duration bounded, scope defaults to all (one widget covers the playlist)', () => {
assert.equal(resolveTransitionConfig({ shader: 'Etch', durationMs: 999999 }).durationMs, 3000);
assert.equal(resolveTransitionConfig({ shader: 'Etch', durationMs: 1 }).durationMs, 150);
assert.equal(resolveTransitionConfig({ shader: 'Etch' }).durationMs, 800, 'missing duration -> default');
assert.equal(resolveTransitionConfig({ shader: 'Etch' }).scope, 'all', 'one widget covers the whole playlist by default');
assert.equal(resolveTransitionConfig({ shader: 'Etch', scope: 'all' }).scope, 'all');
assert.equal(resolveTransitionConfig({ shader: 'Etch', scope: 'next' }).scope, 'next', 'explicit next still honored');
});
test('normalizeTransitions: scope:next attaches to the FOLLOWING item, widget dropped', () => {
const out = normalizeTransitions([IMG('a'), T({ shader: 'CRTCollapse', scope: 'next' }), IMG('b'), IMG('c')]);
assert.equal(out.length, 3, 'transition widget removed from visible list');
assert.deepEqual(out.map((i) => i.content_id), ['a', 'b', 'c']);
assert.equal(out[0].transition, undefined);
assert.equal(out[1].transition.effects[0].shader, 'CRTCollapse', 'plays INTO b');
assert.equal(out[2].transition, undefined);
});
test('normalizeTransitions: scope:all is a playlist default, scope:next overrides it', () => {
const out = normalizeTransitions([
T({ shader: 'Etch', scope: 'all' }),
IMG('a'), IMG('b'),
T({ shader: 'CRTCollapse', scope: 'next' }), IMG('c'),
]);
assert.equal(out.length, 3);
assert.equal(out[0].transition.effects[0].shader, 'Etch', 'default applies to a');
assert.equal(out[1].transition.effects[0].shader, 'Etch', 'default applies to b');
assert.equal(out[2].transition.effects[0].shader, 'CRTCollapse', 'override wins for c');
});
test('normalizeTransitions: a trailing scope:next wraps onto the first item (loop)', () => {
const out = normalizeTransitions([IMG('a'), IMG('b'), T({ shader: 'ReelChange', scope: 'next' })]);
assert.equal(out.length, 2);
assert.equal(out[0].transition.effects[0].shader, 'ReelChange', 'last->first advance');
assert.equal(out[1].transition, undefined);
});
test('normalizeTransitions: unknown-shader transition widget is dropped, no transition attached', () => {
const out = normalizeTransitions([IMG('a'), T({ shader: 'Ghost' }), IMG('b')]);
assert.equal(out.length, 2);
assert.equal(out[1].transition, undefined, 'invalid config -> hard cut, not a black frame');
});
test('normalizeTransitions: non-transition widgets pass through untouched', () => {
const clock = { widget_id: 'c1', widget_type: 'clock' };
const out = normalizeTransitions([IMG('a'), clock, T({ shader: 'Etch', scope: 'next' }), IMG('b')]);
assert.equal(out.length, 3);
assert.equal(out[1].widget_type, 'clock', 'clock widget stays visible');
assert.equal(out[1].transition, undefined);
assert.equal(out[2].transition.effects[0].shader, 'Etch');
});

View file

@ -10,6 +10,7 @@ const commandQueue = require('../lib/command-queue');
const reconnectThrottle = require('../lib/reconnect-throttle');
const contentAckLimiter = require('../lib/content-ack-limiter');
const statusLogWriter = require('../lib/status-log-writer');
const { normalizeTransitions } = require('../lib/transition-config');
const { protectSocket } = require('../lib/safe-socket');
const flapLimiter = require('../lib/flap-limiter');
const sessionSettle = require('../lib/session-settle'); // #148 patch2: eviction-storm debounce
@ -280,6 +281,10 @@ function buildPlaylistPayload(deviceId) {
// instead of binding to a now-gone left/right zone and never playing.
function assemblePayload({ assignments, layout, orientation, wall_config, group_sync, timezone }) {
let a = Array.isArray(assignments) ? assignments : [];
// Transition widgets are normalized OUT here (the single device+preview chokepoint): each is dropped
// from the visible list and its config attached as an opaque `transition` on the item it plays into.
// Old players simply see no transition widget and ignore the field (hard cut) — no regression.
a = normalizeTransitions(a);
const zoneCount = layout?.zones?.length || 0;
if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x));
return {

View file

@ -0,0 +1,38 @@
// CRT Collapse
// blurb: Frame crushes to a line, then a dot, then the next image blooms back out. Power-cycle drama.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float lineHold; // = 0.16 [0.0..0.45]
uniform float flashGain;// = 1.6 [0.0..4.0]
uniform float bloom; // = 14.0 [4.0..40.0]
vec4 transition(vec2 uv){
vec2 c = uv - 0.5;
float half_ = 0.5 * lineHold + 0.5; // unused shaping guard
float pa = clamp(progress / 0.45, 0.0, 1.0);
float pb = clamp((progress - 0.55) / 0.45, 0.0, 1.0);
float vs = 1.0 - smoothstep(0.0, 0.70, pa);
float hs = 1.0 - smoothstep(0.62, 1.0, pa);
float vs2 = smoothstep(0.0, 0.38, pb);
float hs2 = smoothstep(0.30, 1.0, pb);
vec3 col = vec3(0.0);
if(progress < 0.5){
if(abs(c.y) < vs * 0.5 + 0.0016 && abs(c.x) < hs * 0.5 + 0.0016){
vec2 s = vec2(c.x / max(hs, 0.003), c.y / max(vs, 0.003)) + 0.5;
col = getFromColor(clamp(s, 0.0, 1.0)).rgb;
}
} else {
if(abs(c.y) < vs2 * 0.5 + 0.0016 && abs(c.x) < hs2 * 0.5 + 0.0016){
vec2 s = vec2(c.x / max(hs2, 0.003), c.y / max(vs2, 0.003)) + 0.5;
col = getToColor(clamp(s, 0.0, 1.0)).rgb;
}
}
float d = length(vec2(c.x * ratio, c.y));
float flash = exp(-pow(abs(progress - 0.5) * 8.0, 2.0));
col += vec3(1.0, 0.96, 0.88) * flash * exp(-d * bloom) * flashGain;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,37 @@
// Datamosh
// blurb: P-frame corruption — the old frames gradients smear the new one until the blocks give up.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float blockSize; // = 30.0 [6.0..90.0]
uniform float bleed; // = 0.45 [0.0..1.5]
uniform float chroma; // = 0.5 [0.0..2.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float env = sin(3.14159265 * progress);
vec2 bs = vec2(blockSize * ratio, blockSize);
vec2 blk = floor(uv * bs);
vec3 c0 = getFromColor((blk + 0.5) / bs).rgb;
vec3 c1 = getFromColor((blk + vec2(1.5, 0.5)) / bs).rgb;
vec3 c2 = getFromColor((blk + vec2(0.5, 1.5)) / bs).rgb;
vec2 mv = vec2(dot(c1 - c0, vec3(0.333)), dot(c2 - c0, vec3(0.333)));
mv *= bleed * env * 5.0;
float keep = step(progress, bt_hash(blk) * 0.85 + 0.10);
vec3 A = getFromColor(fract(uv + mv)).rgb;
vec2 ub = fract(uv + mv * 0.5);
vec3 B;
float ch = chroma * env * 0.01;
B.r = getToColor(fract(ub + vec2(ch, 0.0))).r;
B.g = getToColor(ub).g;
B.b = getToColor(fract(ub - vec2(ch, 0.0))).b;
vec3 res = mix(B, A, keep);
float q = mix(255.0, 10.0, env);
res = floor(res * q) / q;
return vec4(res, 1.0);
}

View file

@ -0,0 +1,32 @@
// Etch
// blurb: Photomask reveal — the frame develops in on a stepper field, with a hot exposure edge.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float cellSize; // = 26.0 [6.0..90.0]
uniform float edgeGlow; // = 1.0 [0.0..3.0]
uniform float randomness;// = 0.55 [0.0..1.0]
uniform float softness; // = 0.09 [0.005..0.3]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
vec2 g = floor(uv * vec2(cellSize * ratio, cellSize));
float m = bt_hash(g);
float sweep = (uv.x + uv.y) * 0.5;
float mask = clamp(mix(sweep, m, randomness), 0.0, 1.0);
float e = softness;
float pp = progress * (1.0 + e);
float t = smoothstep(mask, mask + e, pp);
vec3 a = getFromColor(uv).rgb;
vec3 b = getToColor(uv).rgb;
vec3 res = mix(a, b, t);
float edge = exp(-abs(pp - mask) / max(e, 0.005) * 1.6);
edge *= (1.0 - smoothstep(0.92, 1.0, progress)) * smoothstep(0.0, 0.05, progress);
res += vec3(1.0, 0.70, 0.22) * edge * edgeGlow * 0.7;
return vec4(res, 1.0);
}

View file

@ -0,0 +1,35 @@
// Fiber Splice
// blurb: Two ends draw apart, the arc fires, and the new frame fuses in from the seam.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float separation; // = 0.30 [0.0..0.5]
uniform float arcGain; // = 1.8 [0.0..4.0]
uniform float arcTight; // = 26.0 [4.0..80.0]
uniform float flicker; // = 0.5 [0.0..1.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float x = uv.x;
float ph = clamp(progress / 0.46, 0.0, 1.0);
float pb = clamp((progress - 0.54) / 0.46, 0.0, 1.0);
vec3 col = vec3(0.0);
if(progress < 0.5){
float off = ph * separation;
if(x < 0.5 - off) col = getFromColor(vec2(x + off, uv.y)).rgb;
else if(x >= 0.5 + off) col = getFromColor(vec2(x - off, uv.y)).rgb;
} else {
float off = (1.0 - pb) * separation;
if(x < 0.5 - off) col = getToColor(vec2(x + off, uv.y)).rgb;
else if(x >= 0.5 + off) col = getToColor(vec2(x - off, uv.y)).rgb;
}
float fl = 1.0 + flicker * (bt_hash(vec2(floor(uv.y * 90.0), floor(progress * 120.0))) - 0.5);
float arc = exp(-abs(x - 0.5) * arcTight) * exp(-pow((progress - 0.5) * 8.5, 2.0)) * fl;
col += vec3(0.72, 0.88, 1.0) * arc * arcGain;
col += vec3(1.0) * arc * arc * 0.6;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,55 @@
// Film Advance
// blurb: The strip pulls through the gate — frame bar and sprockets sweep past, shutter flickers, next frame registers with a bounce.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float frameBar; // = 0.11 [0.02..0.35]
uniform float bounce; // = 0.5 [0.0..1.5]
uniform float shutter; // = 0.55 [0.0..1.0]
uniform float sprockets; // = 1.0 [0.0..1.0]
uniform float grainAmt; // = 0.5 [0.0..1.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float e = progress;
float env = sin(3.14159265 * e);
// pull with a little registration overshoot at the gate
float ease = smoothstep(0.0, 1.0, e);
ease += bounce * 0.045 * sin(e * 18.85) * e * (1.0 - e) * 4.0;
ease = clamp(ease, 0.0, 1.0);
float o = ease * (1.0 + frameBar);
float t = uv.y - o;
vec3 col;
if(t >= 0.0){
col = getFromColor(vec2(uv.x, min(t, 1.0))).rgb;
} else if(t <= -frameBar){
col = getToColor(vec2(uv.x, clamp(t + 1.0 + frameBar, 0.0, 1.0))).rgb;
} else {
float scr = bt_hash(vec2(floor(uv.x * 400.0), floor(e * 12.0)));
col = vec3(0.035, 0.028, 0.022) + vec3(0.10, 0.08, 0.06) * step(0.985, scr);
}
// edge perforations, present only while the strip is moving
float edge = min(uv.x, 1.0 - uv.x);
float band = 1.0 - smoothstep(0.030, 0.038, edge);
float sy = fract((uv.y - o) * (1.0 / (1.0 + frameBar)) * 4.0);
float holeY = 1.0 - smoothstep(0.26, 0.34, abs(sy - 0.5));
float holeX = 1.0 - smoothstep(0.008, 0.014, abs(edge - 0.019));
float perf = holeY * step(0.006, edge) * (1.0 - holeX * 0.0);
vec3 strip = mix(vec3(0.02), vec3(0.86, 0.83, 0.75), perf);
col = mix(col, strip, band * env * sprockets);
// shutter blade
float blade = 1.0 - shutter * env * (0.75 + 0.25 * sin(e * 62.8));
col *= blade;
// gate grain
float g = bt_hash(uv * vec2(720.0, 405.0) + floor(e * 48.0)) - 0.5;
col += vec3(g) * grainAmt * env * 0.20;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,38 @@
// Packet Loss
// blurb: Blocks drop out of the stream, rows tear, and the new frame retransmits block by block.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float cols; // = 26.0 [4.0..80.0]
uniform float rows; // = 15.0 [3.0..48.0]
uniform float rowTear; // = 0.06 [0.0..0.3]
uniform float garbage; // = 0.5 [0.0..1.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float env = sin(3.14159265 * progress);
vec2 bs = vec2(cols, rows);
float rowId = floor(uv.y * rows);
float tear = (bt_hash(vec2(rowId, floor(progress * 24.0))) - 0.5) * rowTear * env;
vec2 uvt = vec2(fract(uv.x + tear), uv.y);
vec2 blk = floor(uvt * bs);
float dropT = 0.05 + bt_hash(blk) * 0.40;
float backT = 0.55 + bt_hash(blk + 11.3) * 0.40;
float gone = step(dropT, progress);
float back = step(backT, progress);
vec3 a = getFromColor(uvt).rgb;
vec3 b = getToColor(uvt).rgb;
vec3 junk = getFromColor(fract(uvt + vec2(bt_hash(blk + 3.1) * 0.5, bt_hash(blk + 5.7) * 0.5))).rgb;
junk = junk.gbr * (0.4 + bt_hash(blk + 9.0) * 0.6);
vec3 hole = mix(vec3(0.0), junk, garbage * step(0.55, bt_hash(blk + 2.2)));
vec3 col = mix(a, hole, gone * (1.0 - back));
col = mix(col, b, back);
return vec4(col, 1.0);
}

View file

@ -0,0 +1,35 @@
// Pixel Sort
// blurb: Columns tear and quantize like glitch art, then resolve on a staggered per-column threshold.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float columns; // = 200.0 [20.0..600.0]
uniform float strength; // = 0.45 [0.0..1.0]
uniform float split; // = 0.012 [0.0..0.06]
uniform float density; // = 0.35 [0.0..0.9]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float env = sin(3.14159265 * progress);
float col = floor(uv.x * columns);
float seed = bt_hash(vec2(col, 3.0));
float active = step(density, seed);
float amt = env * strength * (seed * 2.0 - 1.0) * active;
vec3 a = getFromColor(vec2(uv.x, fract(uv.y + amt))).rgb;
float s = split * env * active;
vec2 ub = vec2(uv.x, fract(uv.y - amt));
vec3 b;
b.r = getToColor(vec2(fract(ub.x + s), ub.y)).r;
b.g = getToColor(ub).g;
b.b = getToColor(vec2(fract(ub.x - s), ub.y)).b;
float q = mix(255.0, 6.0, env);
a = floor(a * q) / q;
b = floor(b * q) / q;
float t = smoothstep(seed * 0.5, seed * 0.5 + 0.5, progress);
return vec4(mix(a, b, t), 1.0);
}

View file

@ -0,0 +1,33 @@
// Quantum Dither
// blurb: Pixels stay undecided, shimmering between both frames, then collapse on an ordered threshold.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float grain; // = 180.0 [20.0..600.0]
uniform float coherence; // = 0.6 [0.0..1.0]
uniform float shimmer; // = 0.5 [0.0..1.0]
uniform float edgeSoft; // = 0.10 [0.01..0.4]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
float bt_bayer2(vec2 a){ a = floor(a); return fract(a.x * 0.5 + a.y * a.y * 0.75); }
float bt_bayer4(vec2 a){ return (bt_bayer2(a * 0.5) * 0.25 + bt_bayer2(a)) / 0.9375; }
vec4 transition(vec2 uv){
vec2 g = floor(uv * vec2(grain * ratio, grain));
float n = bt_hash(g);
float bo = bt_bayer4(g);
float thr = mix(n, bo, coherence);
float w = edgeSoft;
float front = abs(progress - thr);
float undecided = exp(-front / max(w, 0.01) * 1.4);
thr += (bt_hash(g + floor(progress * 45.0) * 17.0) - 0.5) * shimmer * undecided * 0.5;
float t = smoothstep(thr - w, thr + w, progress * (1.0 + 2.0 * w) - w);
vec3 a = getFromColor(uv).rgb;
vec3 b = getToColor(uv).rgb;
vec3 col = mix(a, b, t);
col += vec3(0.18, 0.55, 0.62) * undecided * shimmer * 0.5;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,47 @@
// Reel Change
// blurb: Cue mark burns in the corner, the splice jumps the frame, dust settles on the new reel.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float cueSize; // = 0.045 [0.0..0.12]
uniform float jumpAmt; // = 0.16 [0.0..0.5]
uniform float dust; // = 0.6 [0.0..1.0]
uniform float scratch; // = 0.5 [0.0..1.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float e = progress;
float splice = clamp((e - 0.46) / 0.16, 0.0, 1.0);
float active = step(0.46, e) * step(e, 0.62);
// stepped misregistration through the splice
float k = floor(splice * 4.0);
float off = (bt_hash(vec2(k, 2.0)) - 0.5) * jumpAmt * (1.0 - splice) * active;
vec2 su = vec2(uv.x, fract(uv.y + off));
vec3 a = getFromColor(su).rgb;
vec3 b = getToColor(su).rgb;
vec3 col = mix(a, b, step(0.54, e));
// black frame bar riding through during the splice
float barY = fract(uv.y + off * 3.0 + k * 0.37);
float bar = (1.0 - smoothstep(0.0, 0.045, abs(barY - 0.5))) * active;
col = mix(col, vec3(0.02), bar);
// cue mark, upper right, four-frame flicker
vec2 cp = (uv - vec2(0.865, 0.855)) * vec2(ratio, 1.0);
float ring = 1.0 - smoothstep(cueSize * 0.72, cueSize, length(cp));
float cueWin = step(0.20, e) * step(e, 0.47);
float flick = 0.55 + 0.45 * step(0.5, fract(e * 26.0));
col += vec3(1.0, 0.94, 0.80) * ring * cueWin * flick * 0.85;
// dust and scratches, decaying after the change
float decay = exp(-max(e - 0.54, 0.0) * 9.0) * step(0.46, e);
float sp = bt_hash(uv * vec2(300.0, 170.0) + floor(e * 40.0));
col += vec3(0.9) * step(0.9975, sp) * dust * decay;
float sc = bt_hash(vec2(floor(uv.x * 260.0), floor(e * 20.0)));
col += vec3(0.85, 0.82, 0.74) * step(0.995, sc) * scratch * decay * 0.7;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,36 @@
// Signal Lock
// blurb: Static, rolling sync bars, then the picture snaps in like a tuner acquiring.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float noiseAmount; // = 0.85 [0.0..1.0]
uniform float rollSpeed; // = 2.4 [0.0..8.0]
uniform float barCount; // = 3.0 [0.0..8.0]
uniform float tearAmount; // = 0.12 [0.0..0.5]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float env = sin(3.14159265 * progress); // 0 at both ends
float settle = 1.0 - smoothstep(0.55, 1.0, progress);
vec2 uvr = uv;
uvr.y = fract(uv.y + settle * progress * rollSpeed);
float line = floor(uv.y * 240.0);
float tear = (bt_hash(vec2(line, floor(progress * 30.0))) - 0.5) * tearAmount * env;
uvr.x = fract(uv.x + tear);
vec4 a = getFromColor(uv);
vec4 b = getToColor(uvr);
float sig = smoothstep(0.42, 0.58, progress);
vec4 img = mix(a, b, sig);
float bp = fract(uv.y + progress * barCount);
float bar = smoothstep(0.0, 0.05, bp) * (1.0 - smoothstep(0.05, 0.11, bp));
img.rgb += bar * 0.40 * env;
float st = bt_hash(uv * vec2(640.0, 360.0) + floor(progress * 60.0));
img.rgb = mix(img.rgb, vec3(st), pow(env, 0.55) * noiseAmount * 0.7);
return vec4(img.rgb, 1.0);
}

View file

@ -0,0 +1,34 @@
// Spectrum Sweep
// blurb: An analyzer band crosses the frame, drawing the incoming image as bars before it resolves.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float bandWidth; // = 0.22 [0.05..0.6]
uniform float barCount; // = 64.0 [8.0..200.0]
uniform float glow; // = 1.2 [0.0..3.0]
uniform float floorLift; // = 0.12 [0.0..0.5]
vec4 transition(vec2 uv){
float front = progress * (1.0 + bandWidth) - bandWidth;
vec3 a = getFromColor(uv).rgb;
vec3 b = getToColor(uv).rgb;
if(uv.x < front) return vec4(b, 1.0);
if(uv.x > front + bandWidth) return vec4(a, 1.0);
float k = (uv.x - front) / bandWidth; // 0 at trailing edge, 1 at leading
float bx = (floor(uv.x * barCount) + 0.5) / barCount;
vec3 s = b;
float lum = dot(getToColor(vec2(bx, uv.y)).rgb, vec3(0.299, 0.587, 0.114));
float h = clamp(lum + floorLift, 0.0, 1.0);
float bar = step(uv.y, h) * (0.35 + 0.65 * step(h - 0.02, uv.y));
float gap = smoothstep(0.0, 0.06, fract(uv.x * barCount)) * (1.0 - smoothstep(0.94, 1.0, fract(uv.x * barCount)));
vec3 analyzer = vec3(0.25, 1.0, 0.72) * bar * gap;
vec3 col = mix(s, analyzer, smoothstep(0.15, 0.85, k));
col = mix(col, a, smoothstep(0.75, 1.0, k));
col += vec3(0.4, 1.0, 0.8) * exp(-abs(k - 1.0) * 22.0) * glow;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,38 @@
// Thermal Bloom
// blurb: The frame falls into false colour, blooms hot, and the next image cools back out of it.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float heat; // = 1.0 [0.0..1.0]
uniform float blur; // = 0.010 [0.0..0.05]
uniform float gain; // = 0.35 [0.0..1.2]
vec3 bt_iron(float t){
t = clamp(t, 0.0, 1.0);
return clamp(vec3(
smoothstep(0.05, 0.55, t) * 1.15,
smoothstep(0.40, 0.98, t),
smoothstep(0.0, 0.22, t) * 0.62 - smoothstep(0.22, 0.62, t) * 0.60 + smoothstep(0.78, 1.0, t)
), 0.0, 1.0);
}
vec4 transition(vec2 uv){
float env = sin(3.14159265 * progress);
float t = smoothstep(0.34, 0.66, progress);
float r = blur * env;
vec3 a = vec3(0.0), b = vec3(0.0);
for(int i = 0; i < 5; i++){
float f = (float(i) - 2.0) * 0.5;
a += getFromColor(clamp(uv + vec2(f * r * ratio, f * r), 0.0, 1.0)).rgb;
b += getToColor(clamp(uv + vec2(f * r * ratio, -f * r), 0.0, 1.0)).rgb;
}
a /= 5.0; b /= 5.0;
vec3 real = mix(a, b, t);
float lum = dot(real, vec3(0.299, 0.587, 0.114));
vec3 thermal = bt_iron(lum + env * gain);
vec3 col = mix(real, thermal, env * heat);
return vec4(col, 1.0);
}

View file

@ -0,0 +1,40 @@
// Trace Route
// blurb: Copper traces route across the board and the new frame fills in behind them.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float pitch; // = 30.0 [6.0..90.0]
uniform float wander; // = 0.45 [0.0..1.0]
uniform float traceGlow;// = 1.4 [0.0..3.0]
uniform float traceW; // = 0.10 [0.02..0.35]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
vec2 gv = vec2(pitch * ratio, pitch);
vec2 cell = floor(uv * gv);
vec2 f = fract(uv * gv);
float march = (cell.x / gv.x) * 0.65 + abs(cell.y / gv.y - 0.5) * 0.35;
float d = clamp(mix(march, bt_hash(cell), wander), 0.0, 0.92);
float e = 0.07;
float pp = progress * (1.0 + e);
float fill = smoothstep(d, d + e, pp);
vec3 a = getFromColor(uv).rgb;
vec3 b = getToColor(uv).rgb;
vec3 col = mix(a, b, fill);
float horiz = step(0.5, bt_hash(cell + 4.7));
float line = mix(
1.0 - smoothstep(0.0, traceW, abs(f.x - 0.5)),
1.0 - smoothstep(0.0, traceW, abs(f.y - 0.5)),
horiz);
float pad = 1.0 - smoothstep(0.0, traceW * 1.6, length(f - 0.5));
float front = exp(-abs(pp - d) / max(e, 0.005) * 1.5);
front *= smoothstep(0.0, 0.05, progress) * (1.0 - smoothstep(0.92, 1.0, progress));
col += vec3(1.0, 0.58, 0.20) * max(line, pad) * front * traceGlow;
return vec4(col, 1.0);
}

View file

@ -0,0 +1,46 @@
// Van Eck
// blurb: The next frame reconstructs from raster noise, scanline by scanline, behind an acquisition beam.
// Author: Dan (ByteTinker)
// License: MIT
// GL Transitions v1 — GLSL ES 1.0, runs unmodified in WebGL1 and Android GLES2.
uniform float lineCount; // = 300.0 [60.0..720.0]
uniform float smear; // = 0.07 [0.0..0.3]
uniform float jitter; // = 0.35 [0.0..1.0]
uniform float phosphor; // = 1.0 [0.0..1.0]
float bt_hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec4 transition(vec2 uv){
float gate = smoothstep(0.0, 0.04, progress);
float line = floor(uv.y * lineCount);
float ly = line / lineCount;
float j = bt_hash(vec2(line, 7.0)) * jitter;
float thresh = clamp(ly * (1.0 - jitter) + j, 0.0, 0.8);
float acq = smoothstep(thresh, thresh + 0.18, progress);
vec3 b = vec3(0.0);
float sm = smear * (1.0 - acq);
for(int i = 0; i < 6; i++){
float f = float(i) / 5.0;
b += getToColor(vec2(fract(uv.x + f * sm), uv.y)).rgb;
}
b /= 6.0;
float lum = dot(b, vec3(0.299, 0.587, 0.114));
vec3 phos = vec3(lum) * mix(vec3(1.0), vec3(1.0, 0.72, 0.25), phosphor);
b = mix(phos, b, acq);
float n = bt_hash(uv * vec2(900.0, 500.0) + floor(progress * 90.0));
b = mix(b, vec3(n) * mix(vec3(1.0), vec3(1.0, 0.72, 0.25), phosphor), (1.0 - acq) * 0.55);
vec3 a = getFromColor(uv).rgb;
float wipe = smoothstep(thresh - 0.25, thresh, progress) * gate;
vec3 res = mix(a, b, wipe);
float beam = exp(-abs(progress - thresh) * 55.0) * gate;
res += beam * vec3(1.0, 0.78, 0.34) * 0.55;
res = mix(res, getToColor(uv).rgb, smoothstep(0.96, 1.0, progress));
return vec4(res, 1.0);
}

View file

@ -0,0 +1,178 @@
'use strict';
// Assembles a self-contained interactive demo (demo.html) from the REAL renderer.js + params.js +
// the shader library, so scrubbing it exercises the exact compositor the player will ship. Two
// signage-like images are drawn procedurally (no network assets). `node shared/Transitions/build-demo.js`.
const fs = require('fs');
const path = require('path');
const DIR = __dirname;
const gen = require('./generate-manifest.js');
const rendererSrc = fs.readFileSync(path.join(DIR, 'renderer.js'), 'utf8');
const paramsSrc = fs.readFileSync(path.join(DIR, 'params.js'), 'utf8');
const shaders = gen.build().map((e) => ({
id: e.id, name: e.name, blurb: e.blurb, params: e.params,
src: fs.readFileSync(path.join(DIR, e.file), 'utf8'),
}));
const DATA = JSON.stringify(shaders);
const html = `<title>Transition Compositor — live</title>
<style>
:root{
--bg:#f4f5f7; --panel:#ffffff; --ink:#1a1f2b; --muted:#5b6472; --line:#e2e6ec;
--accent:#0891b2; --accent-ink:#ffffff; --shadow:0 1px 3px rgba(16,24,40,.08),0 8px 24px rgba(16,24,40,.06);
}
@media (prefers-color-scheme:dark){
:root{ --bg:#0d1117; --panel:#161b22; --ink:#e6edf3; --muted:#8b949e; --line:#232b36; --accent:#22d3ee; --accent-ink:#04121a; --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35); }
}
:root[data-theme="dark"]{ --bg:#0d1117; --panel:#161b22; --ink:#e6edf3; --muted:#8b949e; --line:#232b36; --accent:#22d3ee; --accent-ink:#04121a; --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35); }
:root[data-theme="light"]{ --bg:#f4f5f7; --panel:#ffffff; --ink:#1a1f2b; --muted:#5b6472; --line:#e2e6ec; --accent:#0891b2; --accent-ink:#ffffff; --shadow:0 1px 3px rgba(16,24,40,.08),0 8px 24px rgba(16,24,40,.06); }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1080px;margin:0 auto;padding:28px 20px 48px}
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:20px;letter-spacing:-.01em;margin:0;font-weight:650}
.tag{font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);border:1px solid var(--line);padding:5px 8px;border-radius:999px}
.sub{color:var(--muted);margin:0 0 20px;font-size:13.5px}
.stage{background:#000;border-radius:14px;overflow:hidden;box-shadow:var(--shadow);position:relative;aspect-ratio:16/9}
canvas#gl{width:100%;height:100%;display:block}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:18px;margin-top:18px}
.row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.row+.row{margin-top:16px}
label.k{font-size:12px;color:var(--muted);min-width:76px;font-weight:600}
select,button{font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:9px 12px}
button.play{background:var(--accent);color:var(--accent-ink);border-color:transparent;font-weight:650;cursor:pointer;min-width:96px}
select{cursor:pointer;min-width:190px}
input[type=range]{flex:1;accent-color:var(--accent);min-width:180px}
.val{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--muted);min-width:46px;text-align:right;font-variant-numeric:tabular-nums}
.blurb{color:var(--muted);font-size:13px;margin:2px 0 0}
.params{display:grid;grid-template-columns:1fr 1fr;gap:12px 22px;margin-top:4px}
@media (max-width:640px){.params{grid-template-columns:1fr}}
.p{display:flex;align-items:center;gap:10px}
.p label{font-size:12px;min-width:88px;color:var(--muted)}
.thumbs{display:flex;gap:12px;margin-top:14px}
.thumb{flex:1}
.thumb canvas{width:100%;border-radius:9px;border:1px solid var(--line);display:block;aspect-ratio:16/9}
.thumb span{font-size:11px;color:var(--muted);display:block;margin-top:5px;letter-spacing:.04em;text-transform:uppercase;font-weight:600}
.note{font-size:12px;color:var(--muted);margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}
code{font:600 12px ui-monospace,Menlo,monospace;color:var(--accent)}
</style>
<div class="wrap">
<header>
<h1>Transition Compositor</h1><span class="tag">live · renderer.js</span>
</header>
<p class="sub">The actual WebGL compositor the player will ship running in <em>your</em> browser (real GPU). Two local images, one shader, scrub <code>progress</code> 0 1.</p>
<div class="stage"><canvas id="gl" width="1280" height="720"></canvas></div>
<div class="panel">
<div class="row">
<label class="k" for="shader">Shader</label>
<select id="shader"></select>
<button class="play" id="play">Play</button>
</div>
<p class="blurb" id="blurb"></p>
<div class="row">
<label class="k" for="prog">progress</label>
<input type="range" id="prog" min="0" max="1000" value="0">
<span class="val" id="progv">0.000</span>
</div>
<div class="params" id="params"></div>
<div class="thumbs">
<div class="thumb"><canvas id="thA" width="320" height="180"></canvas><span>uFrom image A</span></div>
<div class="thumb"><canvas id="thB" width="320" height="180"></canvas><span>uTo image B</span></div>
</div>
<p class="note">progress 0 = pure A, 1 = pure B. A stays live in <code>uFrom</code> the whole time no blank seam. Shader source, params, and this compositor are the exact files under <code>shared/Transitions/</code>.</p>
</div>
</div>
<script>${paramsSrc}</script>
<script>${rendererSrc}</script>
<script>
const SHADERS = ${DATA};
// PREAMBLE / EPILOGUE / VERTEX / resolveParams are globals from the inlined params.js above.
// --- draw two distinct signage-like source images (no network assets) ---
function paint(cvs, variant){
const c = cvs.getContext('2d'), w = cvs.width, h = cvs.height;
const g = c.createLinearGradient(0,0,w,h);
if(variant==='A'){ g.addColorStop(0,'#f97316'); g.addColorStop(1,'#b91c1c'); }
else { g.addColorStop(0,'#0ea5e9'); g.addColorStop(1,'#155e75'); }
c.fillStyle=g; c.fillRect(0,0,w,h);
c.fillStyle='rgba(255,255,255,.14)';
for(let i=0;i<6;i++){ c.beginPath(); c.arc(variant==='A'?w*0.8:w*0.2, h*0.5, (i+1)*h*0.11, 0, 7); c.fill(); }
c.fillStyle='#fff'; c.textBaseline='middle';
c.font='700 '+Math.round(h*0.16)+'px ui-sans-serif,system-ui,sans-serif';
c.fillText(variant==='A'?'MORNING':'NOW', w*0.09, h*0.4);
c.fillText(variant==='A'?'MENU':'OPEN', w*0.09, h*0.62);
c.font='600 '+Math.round(h*0.07)+'px ui-monospace,monospace';
c.fillStyle='rgba(255,255,255,.8)';
c.fillText(variant==='A'?'image A · uFrom':'image B · uTo', w*0.09, h*0.85);
}
const gl = document.getElementById('gl');
const imgA = document.createElement('canvas'); imgA.width=1280; imgA.height=720; paint(imgA,'A');
const imgB = document.createElement('canvas'); imgB.width=1280; imgB.height=720; paint(imgB,'B');
paint(document.getElementById('thA'),'A'); paint(document.getElementById('thB'),'B');
let renderer, lostBanner=false;
try {
renderer = TransitionRenderer.createRenderer(gl, { PREAMBLE, EPILOGUE, VERTEX }, {
preserveDrawingBuffer:true,
onContextLost:()=>{ lostBanner=true; },
});
renderer.setFrom(imgA); renderer.setTo(imgB);
} catch(e){ document.querySelector('.stage').innerHTML = '<div style="color:#fff;padding:24px;font:14px monospace">WebGL unavailable: '+e.message+'</div>'; }
const sel=document.getElementById('shader'), prog=document.getElementById('prog'),
progv=document.getElementById('progv'), blurb=document.getElementById('blurb'),
paramsBox=document.getElementById('params'), playBtn=document.getElementById('play');
SHADERS.forEach((s,i)=>{ const o=document.createElement('option'); o.value=i; o.textContent=s.name; sel.appendChild(o); });
let cur, curParams={};
function selectShader(i){
cur=SHADERS[i];
try { renderer.setShader(cur.src); } catch(e){ blurb.textContent='compile error: '+e.message; return; }
blurb.textContent=cur.blurb;
curParams=resolveParams(cur.params.map(p=>({name:p.name,default:p.default,min:p.min,max:p.max})), {});
paramsBox.innerHTML='';
cur.params.forEach(p=>{
const wrap=document.createElement('div'); wrap.className='p';
const lab=document.createElement('label'); lab.textContent=p.name;
const r=document.createElement('input'); r.type='range';
r.min=p.min; r.max=p.max; r.step=(p.max-p.min)/200||0.001; r.value=curParams[p.name];
const v=document.createElement('span'); v.className='val'; v.textContent=(+curParams[p.name]).toFixed(2);
r.oninput=()=>{ curParams[p.name]=+r.value; v.textContent=(+r.value).toFixed(2); draw(); };
wrap.appendChild(lab); wrap.appendChild(r); wrap.appendChild(v); paramsBox.appendChild(wrap);
});
draw();
}
function draw(){
if(!renderer) return;
const p=+prog.value/1000; progv.textContent=p.toFixed(3);
renderer.render(p, curParams);
}
sel.onchange=()=>selectShader(+sel.value);
prog.oninput=draw;
let playing=false, raf=0, t0=0;
const DUR=2200, HOLD=550;
function loop(ts){
if(!t0) t0=ts;
const cycle=DUR+HOLD*2, e=(ts-t0)%cycle;
let p = e<HOLD?0 : e>HOLD+DUR?1 : (e-HOLD)/DUR;
p = p<=0?0 : p>=1?1 : (1-Math.cos(p*Math.PI))/2; // ease in-out
prog.value=Math.round(p*1000); draw();
if(playing) raf=requestAnimationFrame(loop);
}
playBtn.onclick=()=>{
playing=!playing; playBtn.textContent=playing?'Pause':'Play';
if(playing){ t0=0; raf=requestAnimationFrame(loop); } else cancelAnimationFrame(raf);
};
selectShader(0);
</script>
`;
fs.writeFileSync(path.join(DIR, 'demo.html'), html);
console.log('Wrote demo.html (' + shaders.length + ' shaders, self-contained).');

View file

@ -0,0 +1,113 @@
'use strict';
// Shader compile test — compiles + links all 14 transition shaders in a REAL WebGL context
// (Chrome via puppeteer-core, ANGLE/SwiftShader) so we catch GLSL ES errors the way a panel would,
// not a lenient CPU validator. Also enforces manifest<->file consistency and that the manifest's
// params never drift from what params.js parses out of the shader source (the single source of truth).
//
// Run: npm run test:shaders (from repo root)
// CI : .github/workflows/shaders.yml
//
// puppeteer-core is Apache-2.0 and uses the system Chrome — no bundled binary is downloaded.
const fs = require('fs');
const path = require('path');
const DIR = __dirname;
const { PREAMBLE, EPILOGUE, VERTEX } = require('./params.js');
const genManifest = require('./generate-manifest.js');
// Resolve puppeteer-core: prefer a real install, else the repo-relative copy in video/ (portable).
function loadPuppeteer() {
const tries = ['puppeteer-core', path.resolve(DIR, '../../video/node_modules/puppeteer-core')];
for (const t of tries) { try { return require(t); } catch (e) { /* next */ } }
console.error('FATAL: puppeteer-core not found. `npm install` at repo root, or set it up in video/.');
process.exit(2);
}
function chromePath() {
const env = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_BIN;
const cands = [env, '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/snap/bin/chromium'].filter(Boolean);
for (const c of cands) { try { if (fs.existsSync(c)) return c; } catch (e) {} }
return null; // let puppeteer try its own resolution
}
function loadShaders() {
const glslFiles = fs.readdirSync(DIR).filter((f) => f.endsWith('.glsl')).sort();
const problems = [];
// The manifest is GENERATED from the shaders (generate-manifest.js). Assert the committed bytes
// equal a fresh regeneration — a mismatch means someone edited a shader without running
// `npm run build:manifest`, so the manifest (and dashboard) would be stale. Staleness = test failure.
const committed = fs.readFileSync(path.join(DIR, 'manifest.json'), 'utf8');
const regenerated = genManifest.serialize(genManifest.build());
if (committed !== regenerated) {
problems.push('manifest.json is stale — run `npm run build:manifest` (shaders changed since it was generated)');
}
// Compile every shader on disk (the manifest is derived, so disk is the authoritative list).
const shaders = glslFiles.map((file) => ({ id: file.replace(/\.glsl$/, ''), file, src: fs.readFileSync(path.join(DIR, file), 'utf8') }));
return { shaders, problems, glslCount: glslFiles.length };
}
// Runs in the browser: compile+link each shader, return [{id, ok, error}]
function browserCompile(jobs) {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return [{ id: '(context)', ok: false, error: 'no WebGL context available' }];
const compile = (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); return { err: log }; }
return { shader: s };
};
const out = [];
const vs = compile(gl.VERTEX_SHADER, jobs.VERTEX);
for (const j of jobs.shaders) {
if (vs.err) { out.push({ id: j.id, ok: false, error: 'vertex: ' + vs.err }); continue; }
const fs_ = compile(gl.FRAGMENT_SHADER, jobs.PREAMBLE + '\n' + j.src + '\n' + jobs.EPILOGUE);
if (fs_.err) { out.push({ id: j.id, ok: false, error: 'fragment: ' + fs_.err }); continue; }
const prog = gl.createProgram();
gl.attachShader(prog, vs.shader); gl.attachShader(prog, fs_.shader);
gl.bindAttribLocation(prog, 0, 'aPos'); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { out.push({ id: j.id, ok: false, error: 'link: ' + gl.getProgramInfoLog(prog) }); }
else out.push({ id: j.id, ok: true });
gl.deleteShader(fs_.shader); gl.deleteProgram(prog);
}
return out;
}
(async () => {
const { shaders, problems, glslCount } = loadShaders();
console.log(`Found ${glslCount} .glsl files, ${shaders.length} in manifest.\n`);
const puppeteer = loadPuppeteer();
const exe = chromePath();
const browser = await puppeteer.launch({
executablePath: exe || undefined,
headless: 'new',
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl'],
});
let results;
try {
const page = await browser.newPage();
page.on('pageerror', (e) => console.error('pageerror:', e.message));
await page.goto('about:blank');
results = await page.evaluate(browserCompile, { shaders, VERTEX, PREAMBLE, EPILOGUE });
} finally {
await browser.close();
}
let failed = 0;
for (const r of results) {
if (r.ok) { console.log(` PASS ${r.id}`); }
else { failed++; console.log(` FAIL ${r.id}\n ${String(r.error).trim().replace(/\n/g, '\n ')}`); }
}
if (problems.length) {
console.log('\nManifest/consistency problems:');
for (const p of problems) console.log(' ✗ ' + p);
} else {
console.log('\nmanifest.json is up to date with the shader sources (byte-identical to a fresh build).');
}
const compiledOk = results.filter((r) => r.ok).length;
console.log(`\n${compiledOk}/${results.length} shaders compiled+linked; ${problems.length} consistency problems.`);
process.exit(failed === 0 && problems.length === 0 ? 0 : 1);
})().catch((e) => { console.error('runner error:', e); process.exit(2); });

View file

@ -0,0 +1,37 @@
'use strict';
// Generate manifest.json from the shader sources — the .glsl files are the SINGLE source of truth.
// Each shader's header carries its display name (first `//` line) and a `// blurb: ...` line; params
// come from parseParams (the same convention the renderer + dashboard read). Nothing is hand-kept in
// the manifest, so it can't drift. `npm run build:manifest` writes it; the compile test byte-compares.
const fs = require('fs');
const path = require('path');
const { parseParams } = require('./params.js');
const DIR = __dirname;
function entryFor(file, src) {
const lines = src.split('\n');
const titleLine = lines.find((l) => /^\/\/\s*\S/.test(l) && !/^\/\/\s*(blurb|author|license|gl transitions)\b/i.test(l));
const name = titleLine ? titleLine.replace(/^\/\/\s*/, '').trim() : file.replace(/\.glsl$/, '');
const blurbLine = lines.find((l) => /^\/\/\s*blurb\s*:/i.test(l));
const blurb = blurbLine ? blurbLine.replace(/^\/\/\s*blurb\s*:\s*/i, '').trim() : '';
const params = parseParams(src).map((p) => ({ name: p.name, default: p.default, min: p.min, max: p.max }));
return { id: file.replace(/\.glsl$/, ''), name, blurb, file, params };
}
function build() {
return fs.readdirSync(DIR)
.filter((f) => f.endsWith('.glsl'))
.sort() // deterministic order so the generated bytes are stable
.map((file) => entryFor(file, fs.readFileSync(path.join(DIR, file), 'utf8')));
}
function serialize(manifest) { return JSON.stringify(manifest, null, 2) + '\n'; }
if (require.main === module) {
const bytes = serialize(build());
fs.writeFileSync(path.join(DIR, 'manifest.json'), bytes);
console.log(`Wrote manifest.json (${build().length} shaders).`);
}
module.exports = { build, serialize };

View file

@ -0,0 +1,438 @@
[
{
"id": "CRTCollapse",
"name": "CRT Collapse",
"blurb": "Frame crushes to a line, then a dot, then the next image blooms back out. Power-cycle drama.",
"file": "CRTCollapse.glsl",
"params": [
{
"name": "lineHold",
"default": 0.16,
"min": 0,
"max": 0.45
},
{
"name": "flashGain",
"default": 1.6,
"min": 0,
"max": 4
},
{
"name": "bloom",
"default": 14,
"min": 4,
"max": 40
}
]
},
{
"id": "Datamosh",
"name": "Datamosh",
"blurb": "P-frame corruption — the old frames gradients smear the new one until the blocks give up.",
"file": "Datamosh.glsl",
"params": [
{
"name": "blockSize",
"default": 30,
"min": 6,
"max": 90
},
{
"name": "bleed",
"default": 0.45,
"min": 0,
"max": 1.5
},
{
"name": "chroma",
"default": 0.5,
"min": 0,
"max": 2
}
]
},
{
"id": "Etch",
"name": "Etch",
"blurb": "Photomask reveal — the frame develops in on a stepper field, with a hot exposure edge.",
"file": "Etch.glsl",
"params": [
{
"name": "cellSize",
"default": 26,
"min": 6,
"max": 90
},
{
"name": "edgeGlow",
"default": 1,
"min": 0,
"max": 3
},
{
"name": "randomness",
"default": 0.55,
"min": 0,
"max": 1
},
{
"name": "softness",
"default": 0.09,
"min": 0.005,
"max": 0.3
}
]
},
{
"id": "FiberSplice",
"name": "Fiber Splice",
"blurb": "Two ends draw apart, the arc fires, and the new frame fuses in from the seam.",
"file": "FiberSplice.glsl",
"params": [
{
"name": "separation",
"default": 0.3,
"min": 0,
"max": 0.5
},
{
"name": "arcGain",
"default": 1.8,
"min": 0,
"max": 4
},
{
"name": "arcTight",
"default": 26,
"min": 4,
"max": 80
},
{
"name": "flicker",
"default": 0.5,
"min": 0,
"max": 1
}
]
},
{
"id": "FilmAdvance",
"name": "Film Advance",
"blurb": "The strip pulls through the gate — frame bar and sprockets sweep past, shutter flickers, next frame registers with a bounce.",
"file": "FilmAdvance.glsl",
"params": [
{
"name": "frameBar",
"default": 0.11,
"min": 0.02,
"max": 0.35
},
{
"name": "bounce",
"default": 0.5,
"min": 0,
"max": 1.5
},
{
"name": "shutter",
"default": 0.55,
"min": 0,
"max": 1
},
{
"name": "sprockets",
"default": 1,
"min": 0,
"max": 1
},
{
"name": "grainAmt",
"default": 0.5,
"min": 0,
"max": 1
}
]
},
{
"id": "PacketLoss",
"name": "Packet Loss",
"blurb": "Blocks drop out of the stream, rows tear, and the new frame retransmits block by block.",
"file": "PacketLoss.glsl",
"params": [
{
"name": "cols",
"default": 26,
"min": 4,
"max": 80
},
{
"name": "rows",
"default": 15,
"min": 3,
"max": 48
},
{
"name": "rowTear",
"default": 0.06,
"min": 0,
"max": 0.3
},
{
"name": "garbage",
"default": 0.5,
"min": 0,
"max": 1
}
]
},
{
"id": "PixelSort",
"name": "Pixel Sort",
"blurb": "Columns tear and quantize like glitch art, then resolve on a staggered per-column threshold.",
"file": "PixelSort.glsl",
"params": [
{
"name": "columns",
"default": 200,
"min": 20,
"max": 600
},
{
"name": "strength",
"default": 0.45,
"min": 0,
"max": 1
},
{
"name": "split",
"default": 0.012,
"min": 0,
"max": 0.06
},
{
"name": "density",
"default": 0.35,
"min": 0,
"max": 0.9
}
]
},
{
"id": "QuantumDither",
"name": "Quantum Dither",
"blurb": "Pixels stay undecided, shimmering between both frames, then collapse on an ordered threshold.",
"file": "QuantumDither.glsl",
"params": [
{
"name": "grain",
"default": 180,
"min": 20,
"max": 600
},
{
"name": "coherence",
"default": 0.6,
"min": 0,
"max": 1
},
{
"name": "shimmer",
"default": 0.5,
"min": 0,
"max": 1
},
{
"name": "edgeSoft",
"default": 0.1,
"min": 0.01,
"max": 0.4
}
]
},
{
"id": "ReelChange",
"name": "Reel Change",
"blurb": "Cue mark burns in the corner, the splice jumps the frame, dust settles on the new reel.",
"file": "ReelChange.glsl",
"params": [
{
"name": "cueSize",
"default": 0.045,
"min": 0,
"max": 0.12
},
{
"name": "jumpAmt",
"default": 0.16,
"min": 0,
"max": 0.5
},
{
"name": "dust",
"default": 0.6,
"min": 0,
"max": 1
},
{
"name": "scratch",
"default": 0.5,
"min": 0,
"max": 1
}
]
},
{
"id": "SignalLock",
"name": "Signal Lock",
"blurb": "Static, rolling sync bars, then the picture snaps in like a tuner acquiring.",
"file": "SignalLock.glsl",
"params": [
{
"name": "noiseAmount",
"default": 0.85,
"min": 0,
"max": 1
},
{
"name": "rollSpeed",
"default": 2.4,
"min": 0,
"max": 8
},
{
"name": "barCount",
"default": 3,
"min": 0,
"max": 8
},
{
"name": "tearAmount",
"default": 0.12,
"min": 0,
"max": 0.5
}
]
},
{
"id": "SpectrumSweep",
"name": "Spectrum Sweep",
"blurb": "An analyzer band crosses the frame, drawing the incoming image as bars before it resolves.",
"file": "SpectrumSweep.glsl",
"params": [
{
"name": "bandWidth",
"default": 0.22,
"min": 0.05,
"max": 0.6
},
{
"name": "barCount",
"default": 64,
"min": 8,
"max": 200
},
{
"name": "glow",
"default": 1.2,
"min": 0,
"max": 3
},
{
"name": "floorLift",
"default": 0.12,
"min": 0,
"max": 0.5
}
]
},
{
"id": "ThermalBloom",
"name": "Thermal Bloom",
"blurb": "The frame falls into false colour, blooms hot, and the next image cools back out of it.",
"file": "ThermalBloom.glsl",
"params": [
{
"name": "heat",
"default": 1,
"min": 0,
"max": 1
},
{
"name": "blur",
"default": 0.01,
"min": 0,
"max": 0.05
},
{
"name": "gain",
"default": 0.35,
"min": 0,
"max": 1.2
}
]
},
{
"id": "TraceRoute",
"name": "Trace Route",
"blurb": "Copper traces route across the board and the new frame fills in behind them.",
"file": "TraceRoute.glsl",
"params": [
{
"name": "pitch",
"default": 30,
"min": 6,
"max": 90
},
{
"name": "wander",
"default": 0.45,
"min": 0,
"max": 1
},
{
"name": "traceGlow",
"default": 1.4,
"min": 0,
"max": 3
},
{
"name": "traceW",
"default": 0.1,
"min": 0.02,
"max": 0.35
}
]
},
{
"id": "VanEck",
"name": "Van Eck",
"blurb": "The next frame reconstructs from raster noise, scanline by scanline, behind an acquisition beam.",
"file": "VanEck.glsl",
"params": [
{
"name": "lineCount",
"default": 300,
"min": 60,
"max": 720
},
{
"name": "smear",
"default": 0.07,
"min": 0,
"max": 0.3
},
{
"name": "jitter",
"default": 0.35,
"min": 0,
"max": 1
},
{
"name": "phosphor",
"default": 1,
"min": 0,
"max": 1
}
]
}
]

View file

@ -0,0 +1,76 @@
// GL Transitions v1 param parser — ByteTinker, MIT
//
// Single source of truth for shader parameters. The `.glsl` files declare their own
// params using the GL Transitions comment convention, extended with an optional range:
//
// uniform float bounce; // = 0.5 [0.0..1.5]
//
// This parser is consumed by:
// - the web player renderer (uniform defaults + values from snapshot config)
// - the Tizen player renderer (same)
// - the dashboard picker (slider min/max/default/step)
//
// There is deliberately no separate param schema. If you find yourself adding one,
// the shader and the UI have already drifted.
const PARAM_RE =
/uniform\s+float\s+(\w+)\s*;\s*\/\/\s*=\s*(-?[\d.]+)\s*(?:\[\s*(-?[\d.]+)\s*\.\.\s*(-?[\d.]+)\s*\])?/g;
/**
* Parse param declarations out of a GLSL source string.
* @param {string} src
* @returns {Array<{name:string, default:number, min:number, max:number, step:number}>}
*/
function parseParams(src) {
const out = [];
let m;
PARAM_RE.lastIndex = 0;
while ((m = PARAM_RE.exec(src))) {
const def = parseFloat(m[2]);
const min = m[3] !== undefined ? parseFloat(m[3]) : Math.min(0, def);
const max = m[4] !== undefined ? parseFloat(m[4]) : (def === 0 ? 1 : def * 2);
out.push({ name: m[1], default: def, min, max, step: (max - min) / 200 });
}
return out;
}
/**
* Merge stored values over defaults, dropping unknown keys and clamping to range.
* Never trust the snapshot blob a shader may have been edited since the playlist
* was configured, and an out-of-range uniform can produce a black frame.
* @param {Array} params result of parseParams
* @param {Object} stored values from snapshot.transition.params
*/
function resolveParams(params, stored) {
const out = {};
for (const p of params) {
const v = stored && typeof stored[p.name] === 'number' ? stored[p.name] : p.default;
out[p.name] = Math.min(p.max, Math.max(p.min, v));
}
return out;
}
// The renderer wraps each .glsl with this. Keep it identical across web, Tizen, and
// Android — the shader sources assume exactly these names and nothing else.
const PREAMBLE = `precision highp float;
varying vec2 vUv;
uniform sampler2D uFrom;
uniform sampler2D uTo;
uniform float progress;
uniform float ratio;
vec4 getFromColor(vec2 uv){ return texture2D(uFrom, uv); }
vec4 getToColor(vec2 uv){ return texture2D(uTo, uv); }
`;
const EPILOGUE = `
void main(){ gl_FragColor = transition(vUv); }`;
const VERTEX = `attribute vec2 aPos;
varying vec2 vUv;
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
if (typeof module !== 'undefined' && module.exports) {
module.exports = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX };
} else if (typeof self !== 'undefined') {
self.TransitionParams = { parseParams, resolveParams, PREAMBLE, EPILOGUE, VERTEX }; // browser (player/demo)
}

View file

@ -0,0 +1,133 @@
// 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.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.TransitionRenderer = factory();
})(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 };
});

View file

@ -18,6 +18,11 @@ rm -f "$OUT"
# .wgt always ships the canonical (byte-identical) copy, never a stale duplicate.
cp ../server/lib/schedule-eval.js js/schedule-eval.js
# transition-engine: rebuild the WebGL transition runtime (params + renderer + shaders) from
# shared/Transitions into the .wgt, same single-source discipline. No npm deps needed.
node -e "require('fs').writeFileSync('js/transitions.js', require('../server/lib/transition-bundle').bundle())" \
&& echo "Rebuilt js/transitions.js from shared/Transitions."
# #119: stamp the player version from the single source (config.xml) so the .wgt's
# reported app_version always matches what is installed — same idea as the copy above.
VER="$(grep -v '<?xml' config.xml | grep -oE 'version="[0-9][^"]*"' | head -1 | sed -E 's/version="([^"]+)"/\1/')"

View file

@ -55,6 +55,7 @@
<script src="$B2BAPIS/b2bapis/b2bapis.js"></script>
<script src="js/socket.io.min.js"></script>
<script src="js/schedule-eval.js"></script>
<script src="js/transitions.js"></script>
<script src="js/player.js"></script>
<script src="js/device-control.js"></script>
<script src="js/pip-overlay.js"></script>

View file

@ -140,8 +140,10 @@ PlaylistPlayer.prototype.load = function (assignments) {
var sig = JSON.stringify(items.map(function (a) {
// STRUCTURAL only. #74/#75: include schedules so a schedule edit (same content) re-renders.
// transition-engine: include the per-item transition, or a transition change keeps the same
// signature -> "unchanged" -> the player never applies the new transitions.
// duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || []];
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
}));
if (sig === this.sig && this.items.length) {
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
@ -360,7 +362,10 @@ PlaylistPlayer.prototype.playCurrent = function () {
&& !(item.widget_id && !item.content_id)
&& mime.indexOf('video/') !== 0
&& mime.indexOf('image/') === 0;
if (!isImage) this.clearStage();
// Skip the pre-dispatch clearStage for an image (it decode-gates + swaps inside renderImage) AND for a
// landscape video that will composite into a wipe (renderVideoBuffered needs the outgoing frame to
// capture as `from`, then clears inside its own mount). Everything else clears up front as before.
if (!isImage && !this._videoWillComposite(item)) this.clearStage();
try {
if (mime === 'video/youtube') return this.renderYouTube(item, single);
@ -419,6 +424,16 @@ PlaylistPlayer.prototype.renderImage = function (item, single) {
var mount = function () {
if (settled) return; settled = true;
if (stale()) { try { img.src = ''; } catch (e) {} return; }
// transition-engine: if this item carries a transition and the outgoing frame is a live image,
// composite instead of hard-swapping. _runImageTransition owns clear+append+schedule+preload, and
// falls back to the plain swap on any failure (never blank). Video is unaffected (AVPlay plane).
var t = item.transition;
if (self._glTxAbort) self._glTxAbort(); // settle any in-flight wipe first — one at a time on the shared renderer
var from = self._texturableStageFrame(); // may snapshot an outgoing <video> -> video→image wipes
if (t && t.effects && t.effects.length && from && self._transitionRuntimeReady()) {
self._runImageTransition(from, img, t, item, targetIdx, single);
return;
}
self.clearStage(); // SWAP: clear only now, with the decoded image ready to paint
self.stage.appendChild(img);
if (!single) {
@ -442,6 +457,207 @@ PlaylistPlayer.prototype.renderImage = function (item, single) {
}
};
// ---- transition-engine: GL image->image transition on the Tizen player ----
// Images only (video lives on the AVPlay hardware plane and can't be textured). Every failure path
// falls back to the plain clear+append swap, so a transition never leaves the screen blank.
PlaylistPlayer.prototype._transitionRuntimeReady = function () {
return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS);
};
PlaylistPlayer.prototype._texturableStageImage = function () {
var img = this.stage.querySelector('img');
return (img && img.complete && img.naturalWidth > 0) ? img : null;
};
// Is there a paintable frame on the stage right now (img OR video)? Cheap existence check (no snapshot),
// used to decide whether a video will composite before we skip the pre-dispatch clearStage.
PlaylistPlayer.prototype._hasStageFrame = function () {
var img = this.stage.querySelector('img');
if (img && img.complete && img.naturalWidth > 0) return true;
var v = this.stage.querySelector('video');
return !!(v && v.readyState >= 2 && v.videoWidth > 0);
};
// The on-stage frame as a texturable source: the live <img>, or — so a wipe can start FROM a playing
// clip (video→image / video→video) — a snapshot canvas of the outgoing <video>'s current frame. A
// cross-origin video with no CORS taints the snapshot; that surfaces later as a texImage2D SecurityError
// in _runGlWipe and hard-cuts (never blank). Returns null if nothing on stage is paintable yet.
PlaylistPlayer.prototype._texturableStageFrame = function () {
var img = this.stage.querySelector('img');
if (img && img.complete && img.naturalWidth > 0) return img;
var v = this.stage.querySelector('video');
if (v && v.readyState >= 2 && v.videoWidth > 0) {
try {
var w = this.stage.clientWidth || 1280, h = this.stage.clientHeight || 720;
var mode = v.className === 'contain' ? 'contain' : (v.className === 'fill' ? 'fill' : 'cover');
return this._fitToCanvas(v, w, h, mode);
} catch (e) { return null; }
}
return null;
};
PlaylistPlayer.prototype._fitMode = function (item) {
var f = (item.fit || item.scale || 'cover').toLowerCase();
if (f === 'contain' || f === 'fit') return 'contain';
if (f === 'fill' || f === 'stretch') return 'fill';
return 'cover';
};
// draw a source image onto a stage-sized canvas honoring the item's fit mode, so the transition frames
// match the static <img>'s object-fit (no pop). Stays texturable iff the source is (else texImage2D throws).
PlaylistPlayer.prototype._fitToCanvas = function (src, w, h, mode) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var cx = c.getContext('2d');
// a <video> exposes its intrinsic size as videoWidth/Height, not naturalWidth/width — check both,
// else the divisor is 0 and drawImage paints NaN/blank.
var iw = src.naturalWidth || src.videoWidth || src.width, ih = src.naturalHeight || src.videoHeight || src.height;
var dw, dh;
if (mode === 'fill') { dw = w; dh = h; }
else { var s = (mode === 'cover') ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih); dw = iw * s; dh = ih * s; }
cx.drawImage(src, (w - dw) / 2, (h - dh) / 2, dw, dh);
return c;
};
// ONE persistent WebGL renderer, reused for every transition. A context per transition leaks GPU
// contexts and chokes real hardware after a few; detaching the canvas between transitions keeps the
// context alive, and only a genuine context loss forces a rebuild.
PlaylistPlayer.prototype._getGlTransition = function () {
if (this._glTx && !this._glTx.renderer.lost) return this._glTx;
try {
var self = this;
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
var renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, {
onContextLost: function () { var a = self._glTxAbort; self._glTx = null; self._glTxAbort = null; if (a) a(); },
});
this._glTx = { canvas: canvas, renderer: renderer };
return this._glTx;
} catch (e) { this._glTx = null; return null; }
};
// Shared GL-wipe core for EVERY Tizen transition — image OR (landscape) video target. Renders
// `fromFrame` (img|video|canvas) -> `toTex` on the persistent canvas; on completion (rAF p>=1, the
// deadline, or context loss) it detaches the canvas (Tizen keeps the context alive by detaching, not
// hiding) and calls mount(), which swaps in the real incoming element (img, or video+play) and owns its
// schedule/preload. onStart() runs once the wipe is committed (the image path schedules its dwell there
// for overlap timing; video advances on 'ended'). Any setup failure / missing runtime / tainted source
// calls hardCut() — never a blank stage. dwellMs bounds the wipe so it can't outlast an image's dwell.
PlaylistPlayer.prototype._runGlWipe = function (fromFrame, toTex, t, item, dwellMs, onStart, mount, hardCut) {
var self = this, stage = this.stage;
var effect = t.effects[Math.floor(Math.random() * t.effects.length)]; // vary among the chosen effects
var src = effect && window.__TRANSITION_SHADERS[effect.shader];
var gl = src ? this._getGlTransition() : null;
if (!gl) { hardCut(); return; } // no shader / no WebGL -> hard cut
var canvas = gl.canvas, renderer = gl.renderer;
var mode = this._fitMode(item);
var w = stage.clientWidth || 1280, h = stage.clientHeight || 720;
var raf = 0, startTs = 0, done = false, deadline = 0;
var finish = function () {
if (done) return; done = true;
if (raf) cancelAnimationFrame(raf);
if (deadline) clearTimeout(deadline);
if (self._glTxAbort === finish) self._glTxAbort = null;
try { if (canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (e) {} // detach, keep context
mount(); // swap in the incoming element + own its schedule/preload
};
try {
renderer.resize(w, h); // size the persistent canvas to the stage
renderer.setFrom(self._fitToCanvas(fromFrame, w, h, mode)); // throws (SecurityError) on a tainted source
renderer.setTo(self._fitToCanvas(toTex, w, h, mode));
renderer.setShader(src); // throws on a bad shader
renderer.render(0, effect.params);
} catch (e) { try { if (canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (x) {} hardCut(); return; }
self._glTxAbort = finish; // context lost mid-transition -> finish (hard cut)
stage.appendChild(canvas); // over the outgoing frame, both live
onStart(); // OVERLAP: image dwell starts now (video: no-op)
var durMs = Math.min(t.durationMs, Math.max(150, dwellMs - 100));
// safety net: mount the target from a timer too, not only rAF — survives rAF throttling/freeze
deadline = setTimeout(finish, durMs + 80);
var frame = function (ts) {
if (done) return;
if (renderer.lost) { finish(); return; }
if (!startTs) startTs = ts;
var p = Math.min(1, (ts - startTs) / durMs);
if (!renderer.render(p, effect.params)) { finish(); return; }
if (p >= 1) { finish(); return; }
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
};
// Image target: wipe fromImg -> toImg, then swap in the plain <img>. Dwell is scheduled at wipe START
// (overlap timing); the mount just swaps the frame (no re-schedule). hardCut = the plain swap.
PlaylistPlayer.prototype._runImageTransition = function (fromImg, toImg, t, item, targetIdx, single) {
var self = this, stage = this.stage;
var dwellMs = this.durationMs(item);
var swapPlain = function () { // plain hard-cut swap (schedules the dwell)
self.clearStage(); stage.appendChild(toImg);
if (!single) { self.schedule(self.durationMs(item)); self.preloadImage(self.nextActiveIndex(targetIdx)); }
};
this._runGlWipe(fromImg, toImg, t, item, dwellMs,
function () { if (!single) self.schedule(dwellMs); }, // onStart: schedule the dwell for overlap
function () { // mount: swap in the image (dwell already scheduled)
self.clearStage(); stage.appendChild(toImg);
if (!single) self.preloadImage(self.nextActiveIndex(targetIdx));
},
swapPlain); // hardCut
};
// Landscape VIDEO target (image→video / video→video): warm-play the incoming clip MUTED offscreen until
// its first frame is PRESENTED (a just-'loadeddata' <video> won't reliably drawImage), freeze it,
// snapshot that frame as the wipe's `to`, run the wipe, then swap in + resume the real <video> from that
// same frame. Every failure path hard-cuts to a plain video mount. Only reached for landscape (portrait
// video rides the AVPlay hardware plane, which can't be textured — always a hard cut there).
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;
var v = document.createElement('video');
this.fit(v, item);
v.muted = true; v.setAttribute('playsinline', ''); // warm-play MUST be muted (autoplay policy)
v.loop = single; // single item loops; multi advances on end
v.style.cssText = '';
var done = false, watchdog = null;
var mountVideo = function () { // full clear + append + resume from the snapshot frame
self.clearStage();
self.currentVideoEl = v; // wall/group drift-correct this (only after clearStage)
stage.appendChild(v);
v.onended = function () { if (!single) self.advance(); };
v.onerror = function () { self.skipSoon(); };
var p = v.play(); if (p && p.catch) p.catch(function () {});
if (!single) {
var secs = Number(item.content_duration || item.duration_sec) || self.DEFAULT_DURATION;
self.schedule((secs + 5) * 1000); // safety net if 'ended' never fires
}
};
var onFirstFrame = function () {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
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;
if (v.readyState >= 2 && v.videoWidth > 0) { try { to = self._fitToCanvas(v, w, h, self._fitMode(item)); } catch (e) { to = null; } }
if (from && to && t && t.effects && t.effects.length && self._transitionRuntimeReady()) {
self._runGlWipe(from, to, t, item, t.durationMs + 200, function () {}, mountVideo, mountVideo);
} else {
mountVideo();
}
};
var armFrame = function () {
if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(function () { onFirstFrame(); });
else setTimeout(onFirstFrame, 150);
};
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
v.src = this.contentUrl(item);
v.load();
};
// A landscape video will composite into the wipe iff it carries a transition, the runtime is ready, and
// there's a texturable frame on stage. Portrait/flipped video uses the AVPlay hardware plane (untexturable)
// -> never composites. playCurrent uses this to skip the pre-dispatch clearStage (keep the outgoing frame);
// renderVideo uses it to route here. Both are synchronous with an unchanged stage, so they always agree.
PlaylistPlayer.prototype._videoWillComposite = function (item) {
var mime = item.mime_type || '';
if (mime.indexOf('video/') !== 0) return false;
if (this.orientation === 'portrait' || this.orientation === 'portrait-flipped') return false;
var t = item.transition;
if (!(t && t.effects && t.effects.length)) return false;
if (!this._transitionRuntimeReady()) return false;
return this._hasStageFrame();
};
PlaylistPlayer.prototype.setOrientation = function (o) { this.orientation = o || 'landscape'; };
PlaylistPlayer.prototype.avAvailable = function () { return !!(window.webapis && webapis.avplay); };
@ -515,6 +731,10 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) {
if ((this.orientation === 'portrait' || this.orientation === 'portrait-flipped') && this.avAvailable()) {
return this.renderVideoAv(item, single);
}
// Landscape: if this video has a transition and there's a texturable outgoing frame, wipe INTO it
// (image→video / video→video). playCurrent skipped the pre-dispatch clearStage for exactly this case,
// so the outgoing frame is still on stage to capture. Plain videos fall through to the proven path.
if (this._videoWillComposite(item)) { return this.renderVideoBuffered(item, single); }
var self = this;
// Double buffer: reuse the pre-buffered element for this index if warmed (no black hold); its src
// is already set + buffering, so playback starts near-instantly.

215
tizen/js/transitions.js Normal file

File diff suppressed because one or more lines are too long