mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
parent
af89eaa75b
commit
96b71a0d56
39
.github/workflows/shaders.yml
vendored
Normal file
39
.github/workflows/shaders.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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: '🕓',
|
||||
weather: '⛅',
|
||||
|
|
@ -16,6 +16,7 @@ const WIDGET_ICONS = {
|
|||
social: '💬',
|
||||
'directory-board': '🏢',
|
||||
'directory-search': '🔍',
|
||||
transition: '🎬',
|
||||
};
|
||||
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
14
package.json
Normal 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
137
server/lib/ssrf-guard.js
Normal 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 };
|
||||
25
server/lib/transition-bundle.js
Normal file
25
server/lib/transition-bundle.js
Normal 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; },
|
||||
};
|
||||
74
server/lib/transition-config.js
Normal file
74
server/lib/transition-config.js
Normal 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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
267
server/routes/media.js
Normal 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 };
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
196
server/test/media-proxy.test.js
Normal file
196
server/test/media-proxy.test.js
Normal 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) {} });
|
||||
39
server/test/ssrf-guard.test.js
Normal file
39
server/test/ssrf-guard.test.js
Normal 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);
|
||||
});
|
||||
}
|
||||
88
server/test/transition-config.test.js
Normal file
88
server/test/transition-config.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
38
shared/Transitions/CRTCollapse.glsl
Normal file
38
shared/Transitions/CRTCollapse.glsl
Normal 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);
|
||||
}
|
||||
37
shared/Transitions/Datamosh.glsl
Normal file
37
shared/Transitions/Datamosh.glsl
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Datamosh
|
||||
// blurb: P-frame corruption — the old frame’s 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);
|
||||
}
|
||||
32
shared/Transitions/Etch.glsl
Normal file
32
shared/Transitions/Etch.glsl
Normal 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);
|
||||
}
|
||||
35
shared/Transitions/FiberSplice.glsl
Normal file
35
shared/Transitions/FiberSplice.glsl
Normal 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);
|
||||
}
|
||||
55
shared/Transitions/FilmAdvance.glsl
Normal file
55
shared/Transitions/FilmAdvance.glsl
Normal 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);
|
||||
}
|
||||
38
shared/Transitions/PacketLoss.glsl
Normal file
38
shared/Transitions/PacketLoss.glsl
Normal 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);
|
||||
}
|
||||
35
shared/Transitions/PixelSort.glsl
Normal file
35
shared/Transitions/PixelSort.glsl
Normal 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);
|
||||
}
|
||||
33
shared/Transitions/QuantumDither.glsl
Normal file
33
shared/Transitions/QuantumDither.glsl
Normal 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);
|
||||
}
|
||||
47
shared/Transitions/ReelChange.glsl
Normal file
47
shared/Transitions/ReelChange.glsl
Normal 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);
|
||||
}
|
||||
36
shared/Transitions/SignalLock.glsl
Normal file
36
shared/Transitions/SignalLock.glsl
Normal 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);
|
||||
}
|
||||
34
shared/Transitions/SpectrumSweep.glsl
Normal file
34
shared/Transitions/SpectrumSweep.glsl
Normal 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);
|
||||
}
|
||||
38
shared/Transitions/ThermalBloom.glsl
Normal file
38
shared/Transitions/ThermalBloom.glsl
Normal 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);
|
||||
}
|
||||
40
shared/Transitions/TraceRoute.glsl
Normal file
40
shared/Transitions/TraceRoute.glsl
Normal 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);
|
||||
}
|
||||
46
shared/Transitions/VanEck.glsl
Normal file
46
shared/Transitions/VanEck.glsl
Normal 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);
|
||||
}
|
||||
178
shared/Transitions/build-demo.js
Normal file
178
shared/Transitions/build-demo.js
Normal 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).');
|
||||
113
shared/Transitions/compile-test.js
Normal file
113
shared/Transitions/compile-test.js
Normal 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); });
|
||||
37
shared/Transitions/generate-manifest.js
Normal file
37
shared/Transitions/generate-manifest.js
Normal 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 };
|
||||
438
shared/Transitions/manifest.json
Normal file
438
shared/Transitions/manifest.json
Normal 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 frame’s 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
76
shared/Transitions/params.js
Normal file
76
shared/Transitions/params.js
Normal 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)
|
||||
}
|
||||
133
shared/Transitions/renderer.js
Normal file
133
shared/Transitions/renderer.js
Normal 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 };
|
||||
});
|
||||
|
|
@ -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/')"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
215
tizen/js/transitions.js
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue