From 96b71a0d569b6dc5cc986891b5b20a6a4256b154 Mon Sep 17 00:00:00 2001 From: screentinker Date: Mon, 20 Jul 2026 16:45:32 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20transition=20engine=20=E2=80=94=20GL=20?= =?UTF-8?q?wipes=20across=20web,=20Tizen=20&=20Android=20(+=20image?= =?UTF-8?q?=E2=86=94video)=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/shaders.yml | 39 ++ .gitignore | 3 + Dockerfile | 4 + android/app/build.gradle.kts | 18 + .../com/remotedisplay/player/MainActivity.kt | 22 +- .../player/player/MediaPlayerManager.kt | 144 ++++-- .../player/player/PlaylistController.kt | 11 +- .../remotedisplay/player/player/Transition.kt | 59 +++ .../player/player/TransitionCompositor.kt | 231 +++++++++ .../player/player/TransitionParseTest.kt | 80 ++++ frontend/js/i18n/en.js | 15 + frontend/js/views/widgets.js | 167 ++++++- package.json | 14 + server/lib/ssrf-guard.js | 137 ++++++ server/lib/transition-bundle.js | 25 + server/lib/transition-config.js | 74 +++ server/player/index.html | 338 +++++++++++++- server/player/sw.js | 2 +- server/routes/media.js | 267 +++++++++++ server/server.js | 14 + server/test/media-proxy.test.js | 196 ++++++++ server/test/ssrf-guard.test.js | 39 ++ server/test/transition-config.test.js | 88 ++++ server/ws/deviceSocket.js | 5 + shared/Transitions/CRTCollapse.glsl | 38 ++ shared/Transitions/Datamosh.glsl | 37 ++ shared/Transitions/Etch.glsl | 32 ++ shared/Transitions/FiberSplice.glsl | 35 ++ shared/Transitions/FilmAdvance.glsl | 55 +++ shared/Transitions/PacketLoss.glsl | 38 ++ shared/Transitions/PixelSort.glsl | 35 ++ shared/Transitions/QuantumDither.glsl | 33 ++ shared/Transitions/ReelChange.glsl | 47 ++ shared/Transitions/SignalLock.glsl | 36 ++ shared/Transitions/SpectrumSweep.glsl | 34 ++ shared/Transitions/ThermalBloom.glsl | 38 ++ shared/Transitions/TraceRoute.glsl | 40 ++ shared/Transitions/VanEck.glsl | 46 ++ shared/Transitions/build-demo.js | 178 +++++++ shared/Transitions/compile-test.js | 113 +++++ shared/Transitions/generate-manifest.js | 37 ++ shared/Transitions/manifest.json | 438 ++++++++++++++++++ shared/Transitions/params.js | 76 +++ shared/Transitions/renderer.js | 133 ++++++ tizen/build-wgt.sh | 5 + tizen/index.html | 1 + tizen/js/player.js | 224 ++++++++- tizen/js/transitions.js | 215 +++++++++ 48 files changed, 3906 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/shaders.yml create mode 100644 android/app/src/main/java/com/remotedisplay/player/player/Transition.kt create mode 100644 android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt create mode 100644 android/app/src/test/java/com/remotedisplay/player/player/TransitionParseTest.kt create mode 100644 package.json create mode 100644 server/lib/ssrf-guard.js create mode 100644 server/lib/transition-bundle.js create mode 100644 server/lib/transition-config.js create mode 100644 server/routes/media.js create mode 100644 server/test/media-proxy.test.js create mode 100644 server/test/ssrf-guard.test.js create mode 100644 server/test/transition-config.test.js create mode 100644 shared/Transitions/CRTCollapse.glsl create mode 100644 shared/Transitions/Datamosh.glsl create mode 100644 shared/Transitions/Etch.glsl create mode 100644 shared/Transitions/FiberSplice.glsl create mode 100644 shared/Transitions/FilmAdvance.glsl create mode 100644 shared/Transitions/PacketLoss.glsl create mode 100644 shared/Transitions/PixelSort.glsl create mode 100644 shared/Transitions/QuantumDither.glsl create mode 100644 shared/Transitions/ReelChange.glsl create mode 100644 shared/Transitions/SignalLock.glsl create mode 100644 shared/Transitions/SpectrumSweep.glsl create mode 100644 shared/Transitions/ThermalBloom.glsl create mode 100644 shared/Transitions/TraceRoute.glsl create mode 100644 shared/Transitions/VanEck.glsl create mode 100644 shared/Transitions/build-demo.js create mode 100644 shared/Transitions/compile-test.js create mode 100644 shared/Transitions/generate-manifest.js create mode 100644 shared/Transitions/manifest.json create mode 100644 shared/Transitions/params.js create mode 100644 shared/Transitions/renderer.js create mode 100644 tizen/js/transitions.js diff --git a/.github/workflows/shaders.yml b/.github/workflows/shaders.yml new file mode 100644 index 0000000..1842425 --- /dev/null +++ b/.github/workflows/shaders.yml @@ -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 diff --git a/.gitignore b/.gitignore index 4138c8f..bb2b8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 4d98af2..9c437b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7422896..f3299d4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index e6c8fb6..d6451d1 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -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 diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index 2daae9a..289564c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -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>? { + 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() { diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index c4483ed..9c6202c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -20,7 +20,9 @@ data class PlaylistItem( val muted: Boolean = false, val widgetId: String? = null, val widgetType: String? = null, - val schedules: List = emptyList() + val schedules: List = 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 diff --git a/android/app/src/main/java/com/remotedisplay/player/player/Transition.kt b/android/app/src/main/java/com/remotedisplay/player/player/Transition.kt new file mode 100644 index 0000000..cb1e414 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/player/Transition.kt @@ -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) + +data class TransitionSpec(val effects: List, 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(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() + 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) + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt b/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt new file mode 100644 index 0000000..ccfd82a --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt @@ -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/.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, + 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, 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() + 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 + } + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/player/TransitionParseTest.kt b/android/app/src/test/java/com/remotedisplay/player/player/TransitionParseTest.kt new file mode 100644 index 0000000..61f0a31 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/player/TransitionParseTest.kt @@ -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 + } +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index f32d555..86fbe97 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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.', diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index dd20fb5..8d585a4 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -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) {
`; break; } + case 'transition': + html += ` +
+
+
${t('widget.trans.multi_hint')}
+
+
+
+ +
+
+ + +
+
0.00
+
+
+
+
+
+ +
${t('widget.trans.scope_hint')}
`; + 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) => ` + `).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 || '', diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd1cb3e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server/lib/ssrf-guard.js b/server/lib/ssrf-guard.js new file mode 100644 index 0000000..4179760 --- /dev/null +++ b/server/lib/ssrf-guard.js @@ -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 /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 }; diff --git a/server/lib/transition-bundle.js b/server/lib/transition-bundle.js new file mode 100644 index 0000000..a2ef899 --- /dev/null +++ b/server/lib/transition-bundle.js @@ -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; }, +}; diff --git a/server/lib/transition-config.js b/server/lib/transition-config.js new file mode 100644 index 0000000..f4be9a9 --- /dev/null +++ b/server/lib/transition-config.js @@ -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 }; diff --git a/server/player/index.html b/server/player/index.html index 92009a2..404d1eb 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -233,6 +233,9 @@ + + + + +`; + +fs.writeFileSync(path.join(DIR, 'demo.html'), html); +console.log('Wrote demo.html (' + shaders.length + ' shaders, self-contained).'); diff --git a/shared/Transitions/compile-test.js b/shared/Transitions/compile-test.js new file mode 100644 index 0000000..6eb1bc4 --- /dev/null +++ b/shared/Transitions/compile-test.js @@ -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); }); diff --git a/shared/Transitions/generate-manifest.js b/shared/Transitions/generate-manifest.js new file mode 100644 index 0000000..49280f8 --- /dev/null +++ b/shared/Transitions/generate-manifest.js @@ -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 }; diff --git a/shared/Transitions/manifest.json b/shared/Transitions/manifest.json new file mode 100644 index 0000000..df226c2 --- /dev/null +++ b/shared/Transitions/manifest.json @@ -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 + } + ] + } +] diff --git a/shared/Transitions/params.js b/shared/Transitions/params.js new file mode 100644 index 0000000..a161530 --- /dev/null +++ b/shared/Transitions/params.js @@ -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) +} diff --git a/shared/Transitions/renderer.js b/shared/Transitions/renderer.js new file mode 100644 index 0000000..177292b --- /dev/null +++ b/shared/Transitions/renderer.js @@ -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 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 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 }; +}); diff --git a/tizen/build-wgt.sh b/tizen/build-wgt.sh index 34a68c3..68709a0 100755 --- a/tizen/build-wgt.sh +++ b/tizen/build-wgt.sh @@ -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 ' + diff --git a/tizen/js/player.js b/tizen/js/player.js index b1d45f8..c2d65d8 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -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