fix(android): improve libass frame timing

This commit is contained in:
edde746
2026-06-24 01:26:46 +02:00
parent b4967ca075
commit c9dc15b1d8
18 changed files with 1887 additions and 195 deletions
+9 -24
View File
@@ -152,26 +152,17 @@ android {
packaging {
jniLibs {
// Three copies of libc++_shared.so reach the merge: the libmpv AAR's
// (NDK r29 — exports std::from_chars<float> that libmpv.so needs), the
// :libass module's CMake-contributed copy (NDK 28.2 — lacks it), and
// peerless2012:ass's bundled copy (also old). pickFirst keeps the merge
// from erroring on the duplicates; WHICH copy wins is pinned by the
// sourceSets block below: extractMpvLibcxx unpacks the libmpv AAR's copy
// into an app jniLibs dir, and PROJECT-scope sources beat sub-projects
// and external AARs. libc++ is backward ABI-compatible, so the older-NDK
// consumers (libass.so, libasskt.so, ffmpeg decoder, cronet) run fine
// against the newer copy.
// pickFirst only suppresses the duplicate libc++ merge error; the
// sourceSets rule below makes libmpv's newer runtime win for
// std::from_chars<float>, while older native consumers remain ABI-compatible.
pickFirsts.add("lib/*/libc++_shared.so")
}
}
sourceSets {
getByName("main") {
// libc++_shared.so extracted from the libmpv AAR by extractMpvLibcxx.
// App source-set jniLibs sit in the PROJECT scope, merged ahead of
// subprojects (:libass) and external AARs, so with the pickFirst rule
// above this copy deterministically wins regardless of dependency order.
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
// order cannot accidentally select the older libc++ copy.
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
}
}
@@ -181,17 +172,15 @@ flutter {
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
}
// Download the libmpv AAR before compilation
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv, extractMpvLibcxx)
}
// merge{Debug,Profile,Release}JniLibFolders snapshot jniLibs source dirs as inputs;
// Gradle 8 requires an explicit dependency on the producing task.
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(extractMpvLibcxx)
}
@@ -216,12 +205,8 @@ dependencies {
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// libass ASS/SSA subtitle rendering: optimized native core (libass.so +
// prefab headers) from the edde746/libass-android fork's releases; Kotlin/JNI
// bindings + Media3 glue live in the android/libass module. -PlocalAssCore
// swaps in a mavenLocal()-published core (0.4.0-local) for native A/B tests.
val assCoreVersion = if (project.hasProperty("localAssCore")) "0.4.0-local" else "0.4.1-plezy.1"
implementation("io.github.peerless2012:ass:$assCoreVersion@aar")
// Keeping libass in-project lets its static core share the app's native
// packaging rules.
implementation(project(":libass"))
testImplementation("junit:junit:4.13.2")
@@ -0,0 +1,196 @@
package com.edde746.plezy.exoplayer
import android.os.Build
import android.view.SurfaceControl
import android.view.SurfaceView
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Auto-calibrates [ExoPlayerCore]'s subtitle/video layer offset by measuring, per device, how much
* later the codec VIDEO plane reaches the display than the GL subtitle OVERLAY plane — the
* inter-plane latency (a deep video VPP on TV boxes) that otherwise makes subtitles a frame ahead.
*
* Both planes are pinned to the same video-frame target (releaseTimeNs). Measuring BOTH with the
* same mechanism — [SurfaceTxProbe] + the API-34 previous-release fence — the per-SoC buffer-recycle
* bias cancels in the difference, leaving the pure offset:
*
* offsetFrames = round( (median(videoReleaseVsTarget) median(overlayReleaseVsTarget)) / frameMs )
*
* This is a MEDIAN-of-each-plane model, NOT a per-frame paired difference: every sample is
* self-paired to its own frame's target, and the per-plane release-vs-target is a steady hardware
* constant, so the two medians need not come from the same frames — only from the same (stable)
* device. Both medians use a recent-sample window so they stay roughly contemporaneous, and a
* confidence gate refuses to converge on an ambiguous (~half-frame) result.
*
* Video samples arrive every few frames from the metadata listener (codec thread); overlay samples
* from the libass GL thread's pre-swap hook (sparse — only when subtitles swap). Once both planes
* have enough samples and the result is confident it converges ONCE, applies + persists, and stops
* all probing (zero steady-state overhead). Re-runs each play to re-confirm.
*
* Threads: probeVideo (codec), probeOverlay (libass GL), onResult (probe reader). The lock guards
* only counter/sample state and is NEVER held across the binder/JNI `applyTransactionToFrame` call
* (so the GL swap path is never stalled by it). The native slot ring is allocated atomically.
*/
internal class AssLatencyCalibrator(
private val videoSurface: SurfaceView,
private val overlaySurface: SurfaceView,
private val onCalibrated: (Int) -> Unit,
private val onDone: () -> Unit,
private val log: (String) -> Unit,
) {
private val lock = Any()
private val finished = AtomicBoolean(false)
private val applyFail = AtomicInteger(0)
@Volatile private var frameIntervalNs: Long = 0L
@Volatile private var converged = false
@Volatile private var stopped = false
private var videoFrames = 0
private var videoAttempts = 0
private var overlaySwaps = 0
private var overlayAttempts = 0
private val videoRel = ArrayDeque<Double>()
private val overlayRel = ArrayDeque<Double>()
fun start() {
SurfaceTxProbe.sink = { tag, latch, release, count, state, source, cb ->
onResult(tag, latch, release, count, state, source, cb)
}
log("calibration started (API ${Build.VERSION.SDK_INT})")
}
fun probeVideo(releaseTimeNs: Long, fps: Float) {
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
if (fps > 1f) frameIntervalNs = (1_000_000_000.0 / fps).toLong()
var doAttach = false
var giveUp = false
synchronized(lock) {
videoFrames++
if (videoFrames > GIVEUP_FRAMES) {
giveUp = true
} else if (videoAttempts < VIDEO_ATTEMPT_CAP && videoFrames % VIDEO_EVERY == 0) {
videoAttempts++
doAttach = true
}
}
if (giveUp) finishIncomplete() else if (doAttach) attach(videoSurface, releaseTimeNs, SurfaceTxProbe.SOURCE_VIDEO)
}
fun probeOverlay(releaseTimeNs: Long) {
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
var doAttach = false
synchronized(lock) {
overlaySwaps++
if (overlayAttempts < OVERLAY_ATTEMPT_CAP && overlaySwaps % OVERLAY_EVERY == 0) {
overlayAttempts++
doAttach = true
}
}
if (doAttach) attach(overlaySurface, releaseTimeNs, SurfaceTxProbe.SOURCE_OVERLAY)
}
/** Never called while holding [lock]: applyTransactionToFrame is a binder call and runs on the
* GL swap path. The native slot allocation is atomic, so concurrent callers are safe. */
private fun attach(surface: SurfaceView, releaseTimeNs: Long, source: Int) {
try {
val tx = SurfaceControl.Transaction()
SurfaceTxProbe.nativeAttach(tx, releaseTimeNs, source)
surface.applyTransactionToFrame(tx)
} catch (t: Throwable) {
if (applyFail.incrementAndGet() == 1) {
log("applyTransactionToFrame failed: ${t.javaClass.simpleName}: ${t.message}")
}
}
}
private fun onResult(
tag: Long,
latchNs: Long,
releaseNs: Long,
surfaceCount: Int,
fenceState: Int,
source: Int,
callbackNs: Long,
) {
if (surfaceCount <= 0 || fenceState != FENCE_OK || releaseNs <= 0) return
val relMs = (releaseNs - tag) / 1_000_000.0
var result: Int? = null
synchronized(lock) {
if (converged || stopped) return
val list = if (source == SurfaceTxProbe.SOURCE_OVERLAY) overlayRel else videoRel
val window = if (source == SurfaceTxProbe.SOURCE_OVERLAY) OVERLAY_WINDOW else VIDEO_WINDOW
list.addLast(relMs)
while (list.size > window) list.removeFirst() // keep a recent window so medians stay fresh
result = tryComputeOffset()
}
result?.let {
onCalibrated(it)
finish()
}
}
private fun tryComputeOffset(): Int? {
val frameMs = frameIntervalNs / 1_000_000.0
if (frameMs <= 0.5) return null
if (videoRel.size < VIDEO_MIN || overlayRel.size < OVERLAY_MIN) return null
val medV = median(videoRel)
val medO = median(overlayRel)
val offsetMs = medV - medO
val raw = offsetMs / frameMs
val frames = raw.roundToInt()
// Confidence gate: only trust a measurement that points clearly at an integer frame count.
// An ambiguous ~half-frame result (corruption / drift / wrong fps) keeps collecting instead.
if (abs(raw - frames) > CONFIDENCE_TOL) return null
val clamped = frames.coerceIn(-2, 2)
converged = true
log(
"CALIBRATED offsetFrames=$clamped raw=${"%.2f".format(raw)} " +
"(video=${"%.1f".format(medV)}ms overlay=${"%.1f".format(medO)}ms = ${"%.1f".format(offsetMs)}ms " +
"/ frameMs=${"%.2f".format(frameMs)}) samples video=${videoRel.size} overlay=${overlayRel.size} " +
"applyFail=${applyFail.get()}"
)
return clamped
}
private fun finishIncomplete() {
if (converged || stopped) return
log(
"calibration incomplete after $videoFrames frames " +
"(video=${videoRel.size} overlay=${overlayRel.size}); keeping seed"
)
finish()
}
fun stop() = finish()
private fun finish() {
if (!finished.compareAndSet(false, true)) return
stopped = true
SurfaceTxProbe.sink = null
onDone()
}
private fun median(xs: Collection<Double>): Double {
val s = xs.sorted()
val n = s.size
return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
}
companion object {
private const val FENCE_OK = 3
private const val VIDEO_EVERY = 2
private const val OVERLAY_EVERY = 1 // overlay swaps are already sparse
private const val VIDEO_MIN = 40
private const val OVERLAY_MIN = 12
private const val VIDEO_WINDOW = 60 // recent-sample window for the median
private const val OVERLAY_WINDOW = 40
private const val VIDEO_ATTEMPT_CAP = 200 // ~16s @24fps/2 of video probing, then the window freezes
private const val OVERLAY_ATTEMPT_CAP = 60
private const val CONFIDENCE_TOL = 0.33 // |raw round(raw)| must be within ⅓ frame to converge
private const val GIVEUP_FRAMES = 1800 // ~75s @24fps without enough confident overlay samples
}
}
@@ -103,6 +103,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
/** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing
* ASS subtitle lag against the libass pipeline's render/swap lines. */
private const val ASS_FRAME_LOGS = false
private const val ASS_SYNC_LOG_INTERVAL_FRAMES = 120L
/** Auto-calibrate the subtitle/video layer offset per device (API 34+) by measuring the
* video vs overlay plane present timing. See [AssLatencyCalibrator]. Falls back to the
* seeded value (persisted calibration or the Dart perf-tier proxy) when off/unsupported. */
private const val ASS_LATENCY_AUTOCAL = true
/** SharedPreferences store for the per-device subtitle/video latency calibration. */
private const val ASS_CAL_PREFS = "plezy_ass_calibration"
private const val ASS_CAL_KEY_FRAMES = "video_latency_frames"
private const val TS_TIMESTAMP_SEARCH_PACKETS = 1800
private val DV_CODEC_PROFILE_REGEX = Regex("""(?:^|,)\s*dvh[1e]\.(\d{2})""")
@@ -158,6 +168,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var videoZoomScale: Float = 1.0f
private var assHandler: AssHandler? = null
private var assSubtitleView: AssSubtitleSurfaceView? = null
// Touched from the codec metadata listener, the GL-thread overlay hook, and the main-thread
// media-item-transition/dispose paths — keep visibility across threads.
@Volatile private var latencyCalibrator: AssLatencyCalibrator? = null
private var assForceMargins = false
private var lastAssMargins: IntArray? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@@ -165,6 +178,17 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var exoPlayer: ExoPlayer? = null
private var renderersFactory: PlezyRenderersFactory? = null
private val subtitleDelayUs = AtomicLong(0L)
/**
* Frames the hardware codec→display path lags a GL subtitle overlay pinned to the
* same release time (the overlay otherwise shows a frame ahead of the picture).
* The subtitle is rendered this many frames earlier to match the later video — a
* content-time shift, so it never delays the overlay's present (delaying the
* present freezes the single-slot latest-wins pipeline). Device-specific: ~1 on
* low-end TV boxes (longer video pipeline), 0 on phones. Set from Dart at init
* from the device performance tier ([com.plezy/device] auto low-end signal).
*/
@Volatile private var assVideoLatencyFrames = 0
private var subtitlePositionPercent: Int = 100
private var subtitleFontSize: Float = 55f
private var lastSubtitleCues: List<Cue> = emptyList()
@@ -233,6 +257,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)
@Volatile private var fpsTimestampCount = 0
private var assSyncFrameCount = 0L
// Audio focus
private var audioFocusManager: AudioFocusManager? = null
@@ -693,7 +718,61 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
exoPlayer!!.addAnalyticsListener(decoderHangListener)
exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
// ASS bypasses Media3's text renderer, so apply sub-delay before libass renders.
assSubtitleView?.requestRender(presentationTimeUs - subtitleDelayUs.get(), releaseTimeNs)
// Also render the subtitle one video-frame earlier than the picture: the
// hardware codec→display path lags a GL overlay pinned to the same release
// time, so without this the overlay shows a frame ahead of the video. Done
// as a content-time shift (not a present delay, which would freeze the
// single-slot latest-wins pipeline by superseding every frame).
val assView = assSubtitleView
val latencyFrames = assVideoLatencyFrames
val fps = detectedFrameRate.takeIf { it > 1f } ?: currentVideoFormat?.frameRate?.takeIf { it > 1f } ?: 0f
val videoLatencyUs = if (latencyFrames != 0 && fps > 1f) {
(latencyFrames * 1_000_000.0 / fps).toLong()
} else {
0L
}
assView?.requestRender(presentationTimeUs - subtitleDelayUs.get() - videoLatencyUs, releaseTimeNs)
if (ASS_LATENCY_AUTOCAL && assView != null && Build.VERSION.SDK_INT >= 34) {
val calibrator = latencyCalibrator ?: surfaceView?.let { sv ->
AssLatencyCalibrator(
videoSurface = sv,
overlaySurface = assView,
onCalibrated = { frames -> onAssLatencyCalibrated(frames) },
onDone = { assSubtitleView?.setPreSwapProbe(null) },
log = { msg -> emitLog("info", "ass-latency-cal", msg) },
).also {
it.start()
// Bind the hook to THIS instance, not the volatile field, so a concurrent transition
// reset can't redirect it to a different/null calibrator mid-swap.
assView.setPreSwapProbe { rt -> it.probeOverlay(rt) }
latencyCalibrator = it
}
}
calibrator?.probeVideo(releaseTimeNs, fps)
}
assSyncFrameCount++
if (assView != null && assSyncFrameCount % ASS_SYNC_LOG_INTERVAL_FRAMES == 0L) {
emitLog(
"info",
"ass-sync",
"frames=$assSyncFrameCount swaps=${assView.swapCount} late=${assView.lateSwapCount} " +
"phaseLeadMs=${assView.phaseLeadMs} swapLeadMs=${assView.swapLeadMs} frameOffMs=${videoLatencyUs / 1000} sleepMs=${assView.lastScheduledSleepMs} " +
"headroomMs=${assView.lastSwapHeadroomMs} leadMs=${assView.lastSwapLeadMs} " +
"minLeadMs=${assView.minLeadChangedMs?.toString() ?: "n/a"} " +
"present=${assView.presentSource} " +
"presentErrMs=${assView.lastPresentErrorMs?.toString() ?: "n/a"} " +
"worstPresentMs=${assView.worstPresentErrorMs?.toString() ?: "n/a"} " +
"presentHist=${assView.presentErrorHistogram.joinToString(",")} " +
"presentMeasured=${assView.presentMeasuredCount} " +
"presentInvalid=${assView.presentInvalidCount} presentDropped=${assView.presentDroppedCount} " +
"render=${assView.changedRenderCount}/${assView.renderCount} " +
"libassMs=${assView.lastLibassMs}/${assView.maxLibassMs} libassHist=${assView.libassMsHistogram.joinToString(",")} " +
"spec=${assView.specHits}/${assView.specMisses}/${assView.specSkips} " +
"prefetch=${assView.prefetchCount} blankClears=${assView.blankClearCount} " +
"coalesced=${assView.coalescedRequestCount} stale=${assView.staleGenerationCount}/${assView.staleBeforeSwapCount} " +
"superseded=${assView.supersededBeforeSwapCount}"
)
}
if (ASS_FRAME_LOGS) {
// Reference stream for subtitle-lag diagnosis: the video frame ExoPlayer
// is releasing right now and how far ahead of its vsync we are. Subtitle
@@ -1313,6 +1392,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
delegate?.onEvent("file-loaded", null)
delegate?.onPropertyChange("eof-reached", false)
emitCurrentSeekable(force = true)
// Re-calibrate the subtitle/video layer offset each play: tear down the converged calibrator
// so the metadata listener lazily spins up a fresh one. The seeded (persisted) value stays
// applied meanwhile, so subtitles are right from the first frame.
latencyCalibrator?.stop()
latencyCalibrator = null
assSubtitleView?.setPreSwapProbe(null)
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -2579,6 +2664,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Reset FPS detection for new content
detectedFrameRate = -1f
fpsTimestampCount = 0
assSyncFrameCount = 0
// Reset DV7 retry flag when opening a different file
if (uri != currentMediaUri) {
@@ -2779,6 +2865,35 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
subtitleDelayUs.set((seconds * 1_000_000).toLong())
}
fun setAssVideoLatencyFrames(frames: Int) {
assVideoLatencyFrames = frames.coerceIn(-2, 2)
}
/**
* Seed the subtitle/video layer offset at init: prefer this device's persisted measured
* calibration, falling back to [proxyDefault] (the Dart perf-tier guess) on first-ever play.
* The live [AssLatencyCalibrator] re-confirms and updates it each play.
*/
fun seedAssVideoLatencyFrames(proxyDefault: Int) {
val stored = activity.getSharedPreferences(ASS_CAL_PREFS, Context.MODE_PRIVATE)
.getInt(ASS_CAL_KEY_FRAMES, Int.MIN_VALUE)
val seed = if (stored != Int.MIN_VALUE) stored else proxyDefault
setAssVideoLatencyFrames(seed)
Log.d(TAG, "ass latency seed=$seed (stored=${if (stored == Int.MIN_VALUE) "none" else stored}, proxy=$proxyDefault)")
}
/** Apply + persist a freshly measured calibration (called by [AssLatencyCalibrator]). */
private fun onAssLatencyCalibrated(frames: Int) {
val clamped = frames.coerceIn(-2, 2)
val previous = assVideoLatencyFrames
setAssVideoLatencyFrames(clamped)
activity.getSharedPreferences(ASS_CAL_PREFS, Context.MODE_PRIVATE)
.edit().putInt(ASS_CAL_KEY_FRAMES, clamped).apply()
if (clamped != previous) {
emitLog("info", "ass-latency-cal", "applied offsetFrames=$clamped (was $previous), persisted")
}
}
fun setDebugDvConversionMode(mode: String): Boolean {
val override = when (mode.trim().lowercase()) {
"auto" -> null
@@ -2820,6 +2935,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
audioDecoderInitName = null
detectedFrameRate = -1f
fpsTimestampCount = 0
assSyncFrameCount = 0
firstFrameRendered = false
currentVideoFormat = null
loggedNativeDvSelectionKey = null
@@ -3227,7 +3343,26 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"subSpecMisses" to assSubtitleView?.specMisses,
"subSpecSkips" to assSubtitleView?.specSkips,
"subPrefetches" to assSubtitleView?.prefetchCount,
"subBlankClears" to assSubtitleView?.blankClearCount,
"subCoalesced" to assSubtitleView?.coalescedRequestCount,
"subStaleGeneration" to assSubtitleView?.staleGenerationCount,
"subSupersededBeforeSwap" to assSubtitleView?.supersededBeforeSwapCount,
"subStaleBeforeSwap" to assSubtitleView?.staleBeforeSwapCount,
"subMinLeadMs" to assSubtitleView?.minLeadChangedMs,
"subPhaseLeadMs" to assSubtitleView?.phaseLeadMs,
"subLastLeadMs" to assSubtitleView?.lastSwapLeadMs,
"subLastHeadroomMs" to assSubtitleView?.lastSwapHeadroomMs,
"subLastSleepMs" to assSubtitleView?.lastScheduledSleepMs,
// Actual on-screen present time vs the video frame's release target (ground
// truth for frame-perfection; null/false on emulator + pre-29 devices).
"subPresentTimingEnabled" to assSubtitleView?.presentTimingEnabled,
"subPresentSource" to assSubtitleView?.presentSource,
"subPresentErrMs" to assSubtitleView?.lastPresentErrorMs,
"subWorstPresentErrMs" to assSubtitleView?.worstPresentErrorMs,
"subPresentMeasured" to assSubtitleView?.presentMeasuredCount,
"subPresentInvalid" to assSubtitleView?.presentInvalidCount,
"subPresentDropped" to assSubtitleView?.presentDroppedCount,
"subPresentErrHist" to assSubtitleView?.presentErrorHistogram,
// Color info
"colorSpace" to videoFormat?.colorInfo?.colorSpace,
"colorRange" to videoFormat?.colorInfo?.colorRange,
@@ -3409,6 +3544,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Synchronous ownership invalidation — stale code can no longer
// reach surface state through instance fields.
latencyCalibrator?.stop()
latencyCalibrator = null
assSubtitleView?.setPreSwapProbe(null)
surfaceContainer = null
videoAspectContainer = null
surfaceView = null
@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -201,7 +202,12 @@ class ExoPlayerPlugin :
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
configuredBufferSizeBytes = bufferSizeBytes
// Global libass overlay render scale — set before the player/handler is built below so the
// first frame-size apply already uses it.
AssHandler.setRenderScale(subtitleRenderScale)
currentActivity.runOnUiThread {
sessionGeneration++
@@ -229,6 +235,8 @@ class ExoPlayerPlugin :
if (success && playerCore?.setDebugDvConversionMode(dvConversionMode) != true) {
Log.w(TAG, "Invalid DV conversion mode during initialize: $dvConversionMode")
}
// Seed from this device's persisted calibration, falling back to the Dart perf-tier proxy.
playerCore?.seedAssVideoLatencyFrames(assVideoLatencyFrames)
// Start hidden
playerCore?.setVisible(false)
@@ -0,0 +1,44 @@
package com.edde746.plezy.exoplayer
import android.view.SurfaceControl
import androidx.annotation.Keep
/**
* EXPERIMENTAL SPIKE: the API-34 transaction callback path is the only in-app signal we have
* for comparing the codec video plane and the libass overlay plane with the same clock.
*/
// R8 cannot see the JNI-by-name callback edge, and class-level @Keep is not enough for members.
@Keep
object SurfaceTxProbe {
init {
System.loadLibrary("asskt")
}
const val SOURCE_VIDEO = 0
const val SOURCE_OVERLAY = 1
// CLOCK_MONOTONIC keeps native callback times comparable with ExoPlayer release targets.
@Volatile
@JvmStatic
var sink: ((Long, Long, Long, Int, Int, Int, Long) -> Unit)? = null
// The same transaction must be applied to the frame so the callback follows that buffer.
@JvmStatic
external fun nativeAttach(transaction: SurfaceControl.Transaction, tag: Long, source: Int)
// JNI calls this by name after shrink.
@Keep
@JvmStatic
fun onResult(
tag: Long,
latchNs: Long,
releaseNs: Long,
surfaceCount: Int,
fenceState: Int,
source: Int,
callbackNs: Long,
) {
val s = sink ?: return
s.invoke(tag, latchNs, releaseNs, surfaceCount, fenceState, source, callbackNs)
}
}
-22
View File
@@ -2,28 +2,6 @@ allprojects {
repositories {
google()
mavenCentral()
if (providers.gradleProperty("localAssCore").isPresent) {
// Opt-in A/B of a locally built libass native core: build it in the
// libass-android fork with `./gradlew :lib_ass:publishToMavenLocal
// -PVERSION_NAME=0.4.0-local`, then build this app with -PlocalAssCore.
mavenLocal()
} else {
// Production libass native core: the -O3/NEON/asm AAR published on
// the edde746/libass-android fork's releases (the upstream Maven
// artifact io.github.peerless2012:ass ships un-optimized natives —
// see the fork's pinned libass-cmake fix). Resolved as
// <tag>/ass-<tag>.aar with no metadata probing.
exclusiveContent {
forRepository {
ivy {
url = uri("https://github.com/edde746/libass-android/releases/download")
patternLayout { artifact("[revision]/[artifact]-[revision].[ext]") }
metadataSources { artifact() }
}
}
filter { includeModule("io.github.peerless2012", "ass") }
}
}
}
}
+9 -33
View File
@@ -1,7 +1,5 @@
// libass ASS subtitle rendering: Kotlin/JNI bindings + Media3 integration
// (extractor, parsers, AssHandler, GL atlas overlay). The native libass core
// (libass.so + prefab headers) comes from the Maven artifact
// io.github.peerless2012:ass; this module compiles its JNI against it.
// Static-linking the native core here avoids shipping a separate libass.so with
// different merge rules from the app.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
@@ -20,34 +18,19 @@ android {
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
if (project.hasProperty("localAssCore")) {
// The locally published A/B core only ships device ABIs (x86 would need
// nasm on the host); match it so prefab resolution doesn't fail.
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
externalNativeBuild {
cmake {
// libass.so in the prefab AAR is built against c++_shared (abi.json: stl=c++_shared);
// prefab validates consumer STL compatibility.
arguments += listOf("-DANDROID_STL=c++_shared")
// HarfBuzz pulls in C++, so the JNI library must use the shared STL that
// the app already pins through libmpv.
// A shared cache keeps per-ABI CMake runs from redownloading libass.
arguments += listOf(
"-DANDROID_STL=c++_shared",
"-DLIBASS_CACHE_DIR=${layout.buildDirectory.get().asFile}/libass-prebuilt",
)
}
}
}
buildFeatures {
prefab = true
}
packaging {
jniLibs {
// libass.so is a prefab IMPORTED target (linked, not owned) — the app packages
// it from the io.github.peerless2012:ass AAR; don't duplicate it here.
excludes.add("**/libass.so")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -66,13 +49,6 @@ android {
}
dependencies {
// compileOnly: prefab headers + link-time libass.so come from the AAR; runtime
// packaging of libass.so is the app's implementation dependency.
// -PlocalAssCore swaps in a mavenLocal()-published core for A/B tests (must
// match the app module's version so one libass.so is linked and packaged).
val assCoreVersion = if (project.hasProperty("localAssCore")) "0.4.0-local" else "0.4.1-plezy.1"
compileOnly("io.github.peerless2012:ass:$assCoreVersion@aar")
implementation("androidx.annotation:annotation:1.9.1")
implementation("androidx.annotation:annotation-experimental:1.5.1")
implementation("androidx.media3:media3-exoplayer:1.9.2")
+219 -10
View File
@@ -1,10 +1,15 @@
// JNI bindings for libass. Exports use standard Java_<package>_<Class>_<method>
// naming so no RegisterNatives/JNI_OnLoad registration is needed.
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <android/log.h>
#include <jni.h>
#include <limits.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
static inline long long nowMs(void) {
@@ -61,7 +66,13 @@ JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssDeinit(JNIEnv*
JNIEXPORT jlong JNICALL
Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackInit(JNIEnv* env, jclass clazz, jlong ass) {
return (jlong)ass_new_track((ASS_Library*)ass);
ASS_Track* track = ass_new_track((ASS_Library*)ass);
if (track != NULL) {
if (ass_track_set_feature(track, ASS_FEATURE_FAST_BLUR, 1) != 0) {
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "ASS_FEATURE_FAST_BLUR unavailable in libass build");
}
}
return (jlong)track;
}
// Shared body of readBuffer/readChunk: pins the byte array and feeds libass.
@@ -132,10 +143,54 @@ JNIEXPORT jlong JNICALL Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackNex
// --- AssRender ---
// The fork's fontconfig build has no Android font search defaults. A tiny
// process-local config lets /system fonts resolve without adding Context/JNI plumbing.
static char* ensureFontsConf(void) {
const char* tmp = getenv("TMPDIR");
if (tmp == NULL || tmp[0] == '\0') tmp = "/data/local/tmp";
char cacheDir[PATH_MAX];
snprintf(cacheDir, sizeof(cacheDir), "%s/fontconfig", tmp);
mkdir(cacheDir, 0700);
char* confPath = (char*)malloc(PATH_MAX);
if (confPath == NULL) return NULL;
snprintf(confPath, PATH_MAX, "%s/fonts.conf", tmp);
FILE* f = fopen(confPath, "w");
if (f == NULL) {
free(confPath);
return NULL;
}
fprintf(
f,
"<?xml version=\"1.0\"?>\n"
"<!DOCTYPE fontconfig SYSTEM \"fonts.dtd\">\n"
"<fontconfig>\n"
" <dir>/system/fonts</dir>\n"
" <dir>/system/font</dir>\n"
" <dir>/product/fonts</dir>\n"
" <dir>/data/fonts</dir>\n"
" <cachedir>%s</cachedir>\n"
"</fontconfig>\n",
cacheDir);
fclose(f);
return confPath;
}
JNIEXPORT jlong JNICALL
Java_com_edde746_plezy_libass_AssRender_nativeAssRenderInit(JNIEnv* env, jclass clazz, jlong ass) {
ASS_Renderer* assRenderer = ass_renderer_init((ASS_Library*)ass);
ass_set_fonts(assRenderer, NULL, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, NULL, 1);
if (assRenderer == NULL) return 0;
unsigned threads = ass_set_threads(assRenderer, 0);
if (threads == 0) {
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "libass threading unavailable in native build");
} else {
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "libass rendering threads enabled: %u", threads);
}
char* fontsConf = ensureFontsConf();
ass_set_fonts(assRenderer, NULL, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, fontsConf, 1);
free(fontsConf);
return (jlong)assRenderer;
}
@@ -195,6 +250,13 @@ static int comparePackItemsByHeightDesc(const void* a, const void* b) {
return ib->img->h - ia->img->h;
}
static int imageListHasOutput(ASS_Image* image) {
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w > 0 && img->h > 0) return 1;
}
return 0;
}
// Throttle for truncation warnings (shared across renderers; logging only).
static int truncationLogCounter = 0;
@@ -214,8 +276,9 @@ static int truncationLogCounter = 0;
// Never fails on content size: images that don't fit the remaining atlas/vertex
// capacity are dropped and counted in AssAtlasFrame.truncated, so a heavy frame
// degrades instead of going stale. Returns NULL only for missing buffers/handles.
// On changed == 0, returns (0, 0, 0, changed, 0) without touching the buffers —
// caller reuses the atlas texture already on the GPU.
// On changed == 0, returns (0, 0, 0, changed, 0, hasOutput) without touching the
// buffers. hasOutput lets Kotlin distinguish "reuse the previous atlas" from
// "the current frame is blank and the GL surface must be cleared."
JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFrameAtlas(
JNIEnv* env, jclass clazz, jlong render, jlong track, jlong time, jobject atlasBuf, jint atlasMaxW, jint atlasMaxH,
jobject vertexBuf) {
@@ -223,7 +286,7 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende
jclass atlasFrameClass = (*env)->FindClass(env, "com/edde746/plezy/libass/AssAtlasFrame");
if (!atlasFrameClass) return NULL;
jmethodID ctor = (*env)->GetMethodID(env, atlasFrameClass, "<init>", "(IIIII)V");
jmethodID ctor = (*env)->GetMethodID(env, atlasFrameClass, "<init>", "(IIIIIZ)V");
if (!ctor) return NULL;
const long long t0 = nowMs();
@@ -231,13 +294,23 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende
ASS_Image* image = ass_render_frame((ASS_Renderer*)render, (ASS_Track*)track, time, &changed);
const long long tAss = nowMs();
if (changed == 0 || image == NULL) {
if (changed == 0) {
const jboolean hasOutput = imageListHasOutput(image) ? JNI_TRUE : JNI_FALSE;
if (tAss - t0 > 40) {
__android_log_print(
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)",
(long long)time, tAss - t0, changed, hasOutput == JNI_TRUE);
}
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, hasOutput);
}
if (image == NULL) {
if (tAss - t0 > 40) {
__android_log_print(
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
tAss - t0, changed);
}
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0);
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, JNI_FALSE);
}
uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf);
@@ -259,7 +332,7 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende
if (img->w > 0 && img->h > 0) total++;
}
if (total == 0) {
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0);
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, JNI_FALSE);
}
// Pass 1: assign packing slots in height-sorted order so mixed-size frames pack
@@ -324,7 +397,7 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende
free(items);
free(slotX);
free(slotY);
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, truncated);
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, truncated, JNI_TRUE);
}
memset(atlasPixels, 0, (size_t)atlasMaxW * packedH);
@@ -432,5 +505,141 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende
// atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width);
// atlasHeight is the packed height — the only rows worth uploading.
return (*env)->NewObject(env, atlasFrameClass, ctor, atlasMaxW, packedH, qi, changed, truncated);
return (*env)->NewObject(env, atlasFrameClass, ctor, atlasMaxW, packedH, qi, changed, truncated, JNI_TRUE);
}
// --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) ---
//
// Measures the overlay buffer's ACTUAL on-screen present time so subtitle
// frame-perfection can be checked against the video frame's release time as
// ground truth, instead of the queue time (eglSwapBuffers return) the swap loop
// otherwise sees. The Java EGLExt only exposes eglPresentationTimeANDROID, so the
// frame-timestamp entry points are resolved here via eglGetProcAddress.
//
// All functions run on the GL thread with the pipeline's EGL context current.
// Older NDK eglext.h may predate the extension; fall back to the spec values.
#ifndef EGL_TIMESTAMPS_ANDROID
#define EGL_TIMESTAMPS_ANDROID 0x3430
#endif
#ifndef EGL_COMPOSITION_LATCH_TIME_ANDROID
#define EGL_COMPOSITION_LATCH_TIME_ANDROID 0x3436
#endif
#ifndef EGL_FIRST_COMPOSITION_START_TIME_ANDROID
#define EGL_FIRST_COMPOSITION_START_TIME_ANDROID 0x3437
#endif
#ifndef EGL_DISPLAY_PRESENT_TIME_ANDROID
#define EGL_DISPLAY_PRESENT_TIME_ANDROID 0x343A
#endif
#ifndef EGL_TIMESTAMP_INVALID_ANDROID
#define EGL_TIMESTAMP_INVALID_ANDROID (-1)
#endif
#ifndef EGL_TIMESTAMP_PENDING_ANDROID
#define EGL_TIMESTAMP_PENDING_ANDROID (-2)
#endif
#ifndef EGL_ANDROID_get_frame_timestamps
typedef khronos_stime_nanoseconds_t EGLnsecsANDROID;
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETNEXTFRAMEIDANDROIDPROC)(EGLDisplay, EGLSurface, EGLuint64KHR*);
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETFRAMETIMESTAMPSANDROIDPROC)(
EGLDisplay, EGLSurface, EGLuint64KHR, EGLint, const EGLint*, EGLnsecsANDROID*);
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC)(EGLDisplay, EGLSurface, EGLint);
#endif
// nativeInit status: success codes are the chosen timestamp source (≥ 0); the
// actual display present time on code 0, and SurfaceFlinger composition timestamps
// (a near-constant ~1-vsync earlier than scanout) on codes 1/2 — a constant bias
// that doesn't hide the inter-layer jitter / multi-vsync outliers we look for.
// Negative codes are failure reasons surfaced to the stats path for diagnosis.
#define FT_SRC_PRESENT 0
#define FT_SRC_COMPOSITION_START 1
#define FT_SRC_COMPOSITION_LATCH 2
#define FT_ERR_NO_SURFACE (-1)
#define FT_ERR_NO_EXTENSION (-2)
#define FT_ERR_NO_PROC (-3)
#define FT_ERR_UNSUPPORTED (-4)
#define FT_ERR_ENABLE_FAILED (-5)
static PFNEGLGETNEXTFRAMEIDANDROIDPROC pEglGetNextFrameId = NULL;
static PFNEGLGETFRAMETIMESTAMPSANDROIDPROC pEglGetFrameTimestamps = NULL;
static PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC pEglGetFrameTimestampSupported = NULL;
static EGLDisplay gFtDisplay = EGL_NO_DISPLAY;
static EGLSurface gFtSurface = EGL_NO_SURFACE;
static EGLint gFtPresentName = EGL_DISPLAY_PRESENT_TIME_ANDROID;
// Probes the extension on the currently-current draw surface and enables capture.
// Re-resolves the display/surface each call so surface recreation is handled.
// Returns one of the FT_* codes above.
JNIEXPORT jint JNICALL
Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeInit(JNIEnv* env, jclass clazz) {
gFtSurface = EGL_NO_SURFACE;
EGLDisplay dpy = eglGetCurrentDisplay();
EGLSurface surf = eglGetCurrentSurface(EGL_DRAW);
if (dpy == EGL_NO_DISPLAY || surf == EGL_NO_SURFACE) return FT_ERR_NO_SURFACE;
const char* exts = eglQueryString(dpy, EGL_EXTENSIONS);
if (exts == NULL || strstr(exts, "EGL_ANDROID_get_frame_timestamps") == NULL) {
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: extension not present");
return FT_ERR_NO_EXTENSION;
}
if (pEglGetNextFrameId == NULL) {
pEglGetNextFrameId = (PFNEGLGETNEXTFRAMEIDANDROIDPROC)eglGetProcAddress("eglGetNextFrameIdANDROID");
pEglGetFrameTimestamps = (PFNEGLGETFRAMETIMESTAMPSANDROIDPROC)eglGetProcAddress("eglGetFrameTimestampsANDROID");
pEglGetFrameTimestampSupported =
(PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC)eglGetProcAddress("eglGetFrameTimestampSupportedANDROID");
}
if (pEglGetNextFrameId == NULL || pEglGetFrameTimestamps == NULL || pEglGetFrameTimestampSupported == NULL) {
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "frame-timestamps: entry points unresolved");
return FT_ERR_NO_PROC;
}
// Prefer true display present; many TV HWCs (e.g. Amlogic) don't report present
// fences but do report SurfaceFlinger composition timestamps. Take the first
// supported — composition timing still pins the swap to a vsync for A-vs-B.
jint status;
if (pEglGetFrameTimestampSupported(dpy, surf, EGL_DISPLAY_PRESENT_TIME_ANDROID)) {
gFtPresentName = EGL_DISPLAY_PRESENT_TIME_ANDROID;
status = FT_SRC_PRESENT;
} else if (pEglGetFrameTimestampSupported(dpy, surf, EGL_FIRST_COMPOSITION_START_TIME_ANDROID)) {
gFtPresentName = EGL_FIRST_COMPOSITION_START_TIME_ANDROID;
status = FT_SRC_COMPOSITION_START;
} else if (pEglGetFrameTimestampSupported(dpy, surf, EGL_COMPOSITION_LATCH_TIME_ANDROID)) {
gFtPresentName = EGL_COMPOSITION_LATCH_TIME_ANDROID;
status = FT_SRC_COMPOSITION_LATCH;
} else {
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: no supported timestamp name");
return FT_ERR_UNSUPPORTED;
}
if (!eglSurfaceAttrib(dpy, surf, EGL_TIMESTAMPS_ANDROID, EGL_TRUE)) {
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "frame-timestamps: enable failed 0x%x", eglGetError());
return FT_ERR_ENABLE_FAILED;
}
gFtDisplay = dpy;
gFtSurface = surf;
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: enabled (source=%d)", status);
return status;
}
// Frame id the next eglSwapBuffers will produce; call immediately before it.
JNIEXPORT jlong JNICALL
Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetNextFrameId(JNIEnv* env, jclass clazz) {
if (pEglGetNextFrameId == NULL || gFtSurface == EGL_NO_SURFACE) return -1;
EGLuint64KHR id = 0;
if (!pEglGetNextFrameId(gFtDisplay, gFtSurface, &id)) return -1;
return (jlong)id;
}
// Present (or composition) time for frameId (System.nanoTime() domain), or the
// PENDING(-2)/INVALID(-1) sentinels. Reported a few frames after the swap.
JNIEXPORT jlong JNICALL
Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetDisplayPresentTime(
JNIEnv* env, jclass clazz, jlong frameId) {
if (pEglGetFrameTimestamps == NULL || gFtSurface == EGL_NO_SURFACE) return EGL_TIMESTAMP_INVALID_ANDROID;
const EGLint names[1] = {gFtPresentName};
EGLnsecsANDROID values[1] = {0};
if (!pEglGetFrameTimestamps(gFtDisplay, gFtSurface, (EGLuint64KHR)frameId, 1, names, values)) {
// Frame id evicted from SurfaceFlinger's history, or bad surface.
return EGL_TIMESTAMP_INVALID_ANDROID;
}
return (jlong)values[0];
}
+39 -5
View File
@@ -2,9 +2,43 @@ cmake_minimum_required(VERSION 3.22.1)
project("asskt")
# libass headers + libass.so come from the io.github.peerless2012:ass AAR via prefab.
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c)
find_package(lib_ass REQUIRED CONFIG)
# Fetching the fork release at configure time keeps per-ABI static archives out
# of git; the pinned hash makes that network step reproducible.
set(LIBASS_VERSION "0.18.1")
set(LIBASS_SHA256 "56088619d3907fabbede978d053eca842c1acdda2fea8f11a1e4e1c5722f0624")
set(LIBASS_ZIP_URL "https://github.com/edde746/libass/releases/download/${LIBASS_VERSION}/libass-android-${LIBASS_VERSION}.zip")
# Gradle passes one cache root so per-ABI CMake builds share the download.
if(NOT DEFINED LIBASS_CACHE_DIR)
set(LIBASS_CACHE_DIR "${CMAKE_BINARY_DIR}/libass-prebuilt")
endif()
set(LIBASS_ROOT "${LIBASS_CACHE_DIR}/libass-android-${LIBASS_VERSION}")
if(NOT EXISTS "${LIBASS_ROOT}/lib/${ANDROID_ABI}/libass.a")
set(_libass_zip "${LIBASS_CACHE_DIR}/libass-android-${LIBASS_VERSION}.zip")
message(STATUS "Fetching fork libass ${LIBASS_VERSION} from ${LIBASS_ZIP_URL}")
file(DOWNLOAD "${LIBASS_ZIP_URL}" "${_libass_zip}"
EXPECTED_HASH "SHA256=${LIBASS_SHA256}"
TLS_VERIFY ON
STATUS _libass_dl)
list(GET _libass_dl 0 _libass_dl_code)
if(NOT _libass_dl_code EQUAL 0)
file(REMOVE "${_libass_zip}")
message(FATAL_ERROR "Failed to download libass: ${_libass_dl}")
endif()
file(ARCHIVE_EXTRACT INPUT "${_libass_zip}" DESTINATION "${LIBASS_CACHE_DIR}")
endif()
add_library(ass STATIC IMPORTED)
set_target_properties(ass PROPERTIES
IMPORTED_LOCATION "${LIBASS_ROOT}/lib/${ANDROID_ABI}/libass.a")
target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include")
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c SurfaceTxProbe.c)
# HarfBuzz brings C++; link the shared STL that the app already packages.
# (SurfaceTxProbe resolves its libandroid/libsync entry points via dlsym, so no extra link.)
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE
lib_ass::ass
log)
ass
log
EGL
c++_shared)
@@ -0,0 +1,321 @@
// EXPERIMENTAL SPIKE — does the codec VIDEO plane's actual present time differ from a
// GL OSD overlay's, and is that difference measurable in-app? (See the memory note
// project_libass_present_time_instrumentation.)
//
// Android exposes no present time for a codec-owned video layer. The one runtime lead is
// API 34 `SurfaceView.applyTransactionToFrame()`: it merges an otherwise-empty,
// callback-bearing transaction with the codec's *next buffer* transaction. The completion
// stats then carry, per surface, the PREVIOUS-RELEASE fence — the moment the buffer the new
// frame replaced stopped being read by the display ≈ when the new codec frame actually
// reached the video plane. Compared in Kotlin to that frame's `releaseTimeNs` target (the
// overlay's own present error is ~0), the delta is the inter-plane lag.
//
// The SurfaceControl transaction-stats / fromJava APIs are API 29/34 while this module's
// minSdk is 21; clang's __builtin_available doesn't guard the Android availability domain in
// this toolchain. So the libandroid entry points are resolved by dlsym (no
// availability-attributed declarations get compiled). Every call site is additionally
// hard-gated to API >= 34 in Kotlin (VideoLayerLatencyProbe).
//
// Fence timestamps are read with the raw SYNC_IOC_FILE_INFO ioctl rather than libsync:
// libsync.so is NOT a public NDK library, so an app's linker namespace blocks dlopen of it.
//
// Compiled into the libass "asskt" .so; called from app Kotlin SurfaceTxProbe.
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <poll.h>
#include <pthread.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <time.h>
#include <unistd.h>
#define LOG_TAG "SurfaceTxProbe"
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#ifndef SYNC_IOC_MAGIC
#define SYNC_IOC_MAGIC '>'
#endif
struct stp_fence_info {
char obj_name[32];
char driver_name[32];
int32_t status;
uint32_t flags;
uint64_t timestamp_ns;
};
struct stp_file_info {
char name[32];
int32_t status;
uint32_t flags;
uint32_t num_fences;
uint32_t pad;
uint64_t sync_fence_info;
};
#ifndef SYNC_IOC_FILE_INFO
#define SYNC_IOC_FILE_INFO _IOWR(SYNC_IOC_MAGIC, 4, struct stp_file_info)
#endif
// libsync is hidden from app linker namespaces, so use the kernel ioctl directly.
static int64_t readFenceTimeNs(int fd, int* outStatus) {
*outStatus = -1;
if (fd < 0) return -1;
struct stp_file_info probe;
memset(&probe, 0, sizeof(probe));
if (ioctl(fd, SYNC_IOC_FILE_INFO, &probe) < 0) return -1;
*outStatus = probe.status;
if (probe.status != 1 || probe.num_fences == 0) return -1;
struct stp_fence_info* arr =
(struct stp_fence_info*)calloc(probe.num_fences, sizeof(struct stp_fence_info));
if (!arr) return -1;
struct stp_file_info req;
memset(&req, 0, sizeof(req));
req.num_fences = probe.num_fences;
req.sync_fence_info = (uint64_t)(uintptr_t)arr;
int64_t t = -1;
if (ioctl(fd, SYNC_IOC_FILE_INFO, &req) == 0) {
for (uint32_t i = 0; i < req.num_fences; i++) {
if ((int64_t)arr[i].timestamp_ns > t) t = (int64_t)arr[i].timestamp_ns;
}
}
free(arr);
return t;
}
typedef struct ASurfaceTransaction ASurfaceTransaction;
typedef struct ASurfaceControl ASurfaceControl;
typedef struct ASurfaceTransactionStats ASurfaceTransactionStats;
typedef void (*OnCompleteFn)(void* context, ASurfaceTransactionStats* stats);
typedef ASurfaceTransaction* (*pf_fromJava)(JNIEnv*, jobject);
typedef void (*pf_setOnComplete)(ASurfaceTransaction*, void*, OnCompleteFn);
typedef int64_t (*pf_latchTime)(ASurfaceTransactionStats*);
typedef void (*pf_getControls)(ASurfaceTransactionStats*, ASurfaceControl***, size_t*);
typedef int (*pf_prevRelease)(ASurfaceTransactionStats*, ASurfaceControl*);
typedef void (*pf_releaseControls)(ASurfaceControl**);
static pf_fromJava p_fromJava = NULL;
static pf_setOnComplete p_setOnComplete = NULL;
static pf_latchTime p_latchTime = NULL;
static pf_getControls p_getControls = NULL;
static pf_prevRelease p_prevRelease = NULL;
static pf_releaseControls p_releaseControls = NULL;
static int gScResolved = 0;
static int gScOk = 0;
static int gScWarningLogged = 0;
// Runtime lookup keeps this API-34 probe buildable with the module's minSdk 21.
static int resolveSc(void) {
if (gScResolved) return gScOk;
gScResolved = 1;
void* h = dlopen("libandroid.so", RTLD_NOW | RTLD_GLOBAL);
if (!h) return 0;
p_fromJava = (pf_fromJava)dlsym(h, "ASurfaceTransaction_fromJava");
p_setOnComplete = (pf_setOnComplete)dlsym(h, "ASurfaceTransaction_setOnComplete");
p_latchTime = (pf_latchTime)dlsym(h, "ASurfaceTransactionStats_getLatchTime");
p_getControls = (pf_getControls)dlsym(h, "ASurfaceTransactionStats_getASurfaceControls");
p_prevRelease = (pf_prevRelease)dlsym(h, "ASurfaceTransactionStats_getPreviousReleaseFenceFd");
p_releaseControls = (pf_releaseControls)dlsym(h, "ASurfaceTransactionStats_releaseASurfaceControls");
gScOk = (p_fromJava && p_setOnComplete && p_latchTime && p_getControls && p_prevRelease &&
p_releaseControls)
? 1
: 0;
return gScOk;
}
// The completion context is pointer-width, so 32-bit devices need an index side-channel.
#define TAG_RING_SIZE 256
static int64_t gTagRing[TAG_RING_SIZE];
static int gSrcRing[TAG_RING_SIZE];
static int gTagSeq = 0;
static JavaVM* gVm = NULL;
static jclass gProbeClass = NULL;
static jmethodID gOnResult = NULL;
static int gCallbackLookupAttempted = 0;
static pthread_mutex_t gInitLock = PTHREAD_MUTEX_INITIALIZER;
static int64_t nowMonoNs(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
static void reportResult(JNIEnv* env, int64_t tag, int64_t latchNs, int64_t releaseNs, int count,
int fenceState, int source, int64_t cbNs) {
if (!env || !gProbeClass || !gOnResult) return;
(*env)->CallStaticVoidMethod(env, gProbeClass, gOnResult, (jlong)tag, (jlong)latchNs,
(jlong)releaseNs, (jint)count, (jint)fenceState, (jint)source,
(jlong)cbNs);
if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
}
// Previous-release fences can still be pending in the binder callback; a worker
// thread keeps SurfaceFlinger's callback dispatch unblocked.
struct fenceJob {
int fd;
int64_t tag;
int64_t latch;
int count;
int source;
};
#define JOB_RING 64
static struct fenceJob gJobs[JOB_RING];
static int gJobHead = 0, gJobTail = 0;
static int gJobDrops = 0;
static pthread_mutex_t gJobLock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t gJobCond = PTHREAD_COND_INITIALIZER;
static pthread_t gReader;
static int gReaderStarted = 0;
static void* readerMain(void* arg);
static int ensureProbeInit(JNIEnv* env, jclass clazz) {
int scOk = 0;
pthread_mutex_lock(&gInitLock);
if (!gVm && (*env)->GetJavaVM(env, &gVm) != JNI_OK) {
LOGW("GetJavaVM failed — transaction probe disabled");
pthread_mutex_unlock(&gInitLock);
return 0;
}
if (!gProbeClass) {
gProbeClass = (*env)->NewGlobalRef(env, clazz);
if (!gProbeClass) {
LOGW("NewGlobalRef(SurfaceTxProbe) failed — transaction probe disabled");
pthread_mutex_unlock(&gInitLock);
return 0;
}
}
if (!gCallbackLookupAttempted) {
gCallbackLookupAttempted = 1;
gOnResult = (*env)->GetStaticMethodID(env, clazz, "onResult", "(JJJIIIJ)V");
if (!gOnResult) {
(*env)->ExceptionClear(env);
LOGW("onResult(JJJIIIJ)V not found — JNI callback disabled (R8 stripped it?)");
}
}
if (!gOnResult) {
pthread_mutex_unlock(&gInitLock);
return 0;
}
scOk = resolveSc();
if (!scOk) {
if (!gScWarningLogged) {
gScWarningLogged = 1;
LOGW("libandroid SurfaceControl transaction-stats API unavailable");
}
pthread_mutex_unlock(&gInitLock);
return 0;
}
if (!gReaderStarted) {
const int rc = pthread_create(&gReader, NULL, readerMain, NULL);
if (rc != 0) {
LOGW("fence reader thread start failed: %d", rc);
pthread_mutex_unlock(&gInitLock);
return 0;
}
gReaderStarted = 1;
}
pthread_mutex_unlock(&gInitLock);
return scOk;
}
static void* readerMain(void* arg) {
(void)arg;
JNIEnv* env = NULL;
if ((*gVm)->AttachCurrentThread(gVm, &env, NULL) != JNI_OK) return NULL;
for (;;) {
pthread_mutex_lock(&gJobLock);
while (gJobHead == gJobTail) pthread_cond_wait(&gJobCond, &gJobLock);
struct fenceJob job = gJobs[gJobTail];
gJobTail = (gJobTail + 1) % JOB_RING;
pthread_mutex_unlock(&gJobLock);
int64_t releaseNs = -1;
int fenceState;
struct pollfd pfd = {.fd = job.fd, .events = POLLIN, .revents = 0};
int pr = poll(&pfd, 1, 250);
if (pr > 0 && (pfd.revents & POLLIN)) {
int st = -1;
releaseNs = readFenceTimeNs(job.fd, &st);
fenceState = (releaseNs > 0) ? 3 : 1;
} else {
fenceState = 2;
}
close(job.fd);
reportResult(env, job.tag, job.latch, releaseNs, job.count, fenceState, job.source, nowMonoNs());
}
return NULL;
}
static void onComplete(void* context, ASurfaceTransactionStats* stats) {
int slot = (int)(intptr_t)context;
__atomic_thread_fence(__ATOMIC_ACQUIRE);
int64_t tag = gTagRing[slot & (TAG_RING_SIZE - 1)];
int source = gSrcRing[slot & (TAG_RING_SIZE - 1)];
int64_t latchNs = p_latchTime ? p_latchTime(stats) : -1;
// Display-global present fences are abort-prone; per-surface release fences are stable here.
int chosenFd = -1;
int count = 0;
ASurfaceControl** controls = NULL;
size_t n = 0;
if (p_getControls) p_getControls(stats, &controls, &n);
count = (int)n;
for (size_t i = 0; i < n; i++) {
int rfd = p_prevRelease ? p_prevRelease(stats, controls[i]) : -1;
if (rfd < 0) continue;
if (chosenFd < 0)
chosenFd = rfd;
else
close(rfd);
}
if (controls && p_releaseControls) p_releaseControls(controls);
if (chosenFd < 0) {
JNIEnv* env = NULL;
int didAttach = 0;
if ((*gVm)->GetEnv(gVm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
if ((*gVm)->AttachCurrentThread(gVm, &env, NULL) != JNI_OK) return;
didAttach = 1;
}
reportResult(env, tag, latchNs, -1, count, 0, source, nowMonoNs());
if (didAttach) (*gVm)->DetachCurrentThread(gVm);
return;
}
pthread_mutex_lock(&gJobLock);
int next = (gJobHead + 1) % JOB_RING;
if (next == gJobTail) {
close(chosenFd);
if (gJobDrops++ == 0) LOGW("fence job ring full — dropping samples (slow fence reader?)");
} else {
gJobs[gJobHead].fd = chosenFd;
gJobs[gJobHead].tag = tag;
gJobs[gJobHead].latch = latchNs;
gJobs[gJobHead].count = count;
gJobs[gJobHead].source = source;
gJobHead = next;
pthread_cond_signal(&gJobCond);
}
pthread_mutex_unlock(&gJobLock);
}
JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_SurfaceTxProbe_nativeAttach(
JNIEnv* env, jclass clazz, jobject jtransaction, jlong tag, jint source) {
if (!ensureProbeInit(env, clazz)) return;
int slot = __atomic_fetch_add(&gTagSeq, 1, __ATOMIC_RELAXED) & (TAG_RING_SIZE - 1);
gTagRing[slot] = (int64_t)tag;
gSrcRing[slot] = (int)source;
// Binder carries only the slot index, so the ring write has to win publication.
__atomic_thread_fence(__ATOMIC_RELEASE);
ASurfaceTransaction* tx = p_fromJava(env, jtransaction);
if (!tx) {
LOGW("ASurfaceTransaction_fromJava returned null");
return;
}
p_setOnComplete(tx, (void*)(intptr_t)slot, onComplete);
}
@@ -11,11 +11,23 @@ package com.edde746.plezy.libass
* @param truncated images dropped because they exceeded the atlas/vertex capacity;
* the frame is incomplete but never stale (> 0 should be rare —
* it means even the GL-max-sized atlas couldn't fit the frame)
* @param hasOutput true when libass reported at least one visible image for this
* timestamp, even when [changed] is 0 and the buffers were not
* rewritten. false means this timestamp should be blank.
*/
class AssAtlasFrame(
val atlasWidth: Int,
val atlasHeight: Int,
val quadCount: Int,
val changed: Int,
val truncated: Int
)
val truncated: Int,
val hasOutput: Boolean
) {
constructor(
atlasWidth: Int,
atlasHeight: Int,
quadCount: Int,
changed: Int,
truncated: Int
) : this(atlasWidth, atlasHeight, quadCount, changed, truncated, quadCount > 0)
}
@@ -0,0 +1,71 @@
package com.edde746.plezy.libass
/**
* Thin binding over the `EGL_ANDROID_get_frame_timestamps` extension.
*
* The atlas pipeline pins each overlay swap to the video frame's `releaseTimeNs`,
* but the swap loop can only measure when [android.opengl.EGL14.eglSwapBuffers]
* *returns* — i.e. when the buffer was queued, not when SurfaceFlinger actually
* presented it. That can't reveal a one-vsync compositor latch offset, which is
* exactly the error frame-perfection work chases. This binding reads the buffer's
* *actual on-screen present time* so it can be compared against `releaseTimeNs`
* (the video frame's vsync target) as ground truth.
*
* `android.opengl.EGLExt` exposes only `eglPresentationTimeANDROID`; the
* frame-timestamp entry points are resolved natively via `eglGetProcAddress`.
*
* Requires Android 10 (API 29)+ and the extension; probe with [nativeInit].
* All calls must run on the GL thread with the pipeline's EGL context current.
*/
internal object AssFrameTimestamps {
/** Present time not yet reported by SurfaceFlinger; query the frame again later. */
const val PENDING = -2L
/** The frame was dropped/never presented, or its id was evicted from history. */
const val INVALID = -1L
// [nativeInit] status. Success codes are the timestamp source in use (≥ 0):
// true scanout present (SRC_PRESENT) or — where the HWC reports no present fence,
// common on TV SoCs — a SurfaceFlinger composition timestamp ~1 vsync earlier
// (SRC_COMPOSITION_*; a constant bias, harmless to the jitter/outlier analysis).
// Negative codes are failure reasons, surfaced to the stats path for diagnosis.
const val SRC_PRESENT = 0
const val SRC_COMPOSITION_START = 1
const val SRC_COMPOSITION_LATCH = 2
const val ERR_NO_SURFACE = -1
const val ERR_NO_EXTENSION = -2
const val ERR_NO_PROC = -3
const val ERR_UNSUPPORTED = -4
const val ERR_ENABLE_FAILED = -5
/** Short label for a [nativeInit] status code, for logs/getStats. */
fun sourceLabel(status: Int): String = when (status) {
SRC_PRESENT -> "present"
SRC_COMPOSITION_START -> "comp-start"
SRC_COMPOSITION_LATCH -> "comp-latch"
ERR_NO_SURFACE -> "off:no-surface"
ERR_NO_EXTENSION -> "off:no-ext"
ERR_NO_PROC -> "off:no-proc"
ERR_UNSUPPORTED -> "off:unsupported"
ERR_ENABLE_FAILED -> "off:enable-failed"
else -> "off:$status"
}
/**
* Probes the extension on the currently-current draw surface and enables
* timestamp capture, preferring true present then composition timestamps.
* Returns an `SRC_*` code (≥ 0) on success or an `ERR_*` code (< 0). Re-resolves
* the surface each call, so it is safe to re-invoke after surface recreation.
*/
@JvmStatic external fun nativeInit(): Int
/** Frame id the next `eglSwapBuffers` will produce; call immediately before it. */
@JvmStatic external fun nativeGetNextFrameId(): Long
/**
* Present (or composition) time for [frameId] in the `System.nanoTime()` domain,
* or the [PENDING]/[INVALID] sentinels. Results are deferred a few frames past
* the swap, so a caller drains pending ids lazily.
*/
@JvmStatic external fun nativeGetDisplayPresentTime(frameId: Long): Long
}
@@ -17,6 +17,7 @@ import com.edde746.plezy.libass.Ass
import com.edde746.plezy.libass.AssRender
import com.edde746.plezy.libass.AssTrack
import com.edde746.plezy.libass.media.parser.AssHeaderParser
import com.edde746.plezy.libass.media.widget.AssAtlasPipelineConfig
/**
* Handles ASS subtitle rendering and integration with ExoPlayer.
@@ -185,13 +186,15 @@ class AssHandler(
* (re)created or the selected track changes, so renderer state survives both.
*/
private fun applyRenderState(render: AssRender) {
// Storage (script/video authoring size) stays full-res; only the frame and
// margins follow RENDER_SCALE so the raster shrinks while layout is unchanged.
if (videoSize.isValid) render.setStorageSize(videoSize.width, videoSize.height)
when {
surfaceSize.isValid -> render.setFrameSize(surfaceSize.width, surfaceSize.height)
surfaceSize.isValid -> render.setFrameSize(scaledForRender(surfaceSize.width), scaledForRender(surfaceSize.height))
// Fallback frame until the overlay surface reports its size.
videoSize.isValid -> render.setFrameSize(videoSize.width, videoSize.height)
videoSize.isValid -> render.setFrameSize(scaledForRender(videoSize.width), scaledForRender(videoSize.height))
}
margins?.let { m -> render.setMargins(m[0], m[1], m[2], m[3]) }
margins?.let { m -> render.setMargins(scaledForRender(m[0]), scaledForRender(m[1]), scaledForRender(m[2]), scaledForRender(m[3])) }
render.setUseMargins(useMargins)
}
@@ -220,7 +223,7 @@ class AssHandler(
if (surfaceSize.width == width && surfaceSize.height == height) return
Log.i("AssHandler", "setOverlaySurfaceSize: width = $width, height = $height")
surfaceSize = Size(width, height)
render?.setFrameSize(width, height)
render?.setFrameSize(scaledForRender(width), scaledForRender(height))
}
/**
@@ -230,9 +233,13 @@ class AssHandler(
*/
fun setMargins(top: Int, bottom: Int, left: Int, right: Int) {
margins = intArrayOf(top, bottom, left, right)
render?.setMargins(top, bottom, left, right)
render?.setMargins(scaledForRender(top), scaledForRender(bottom), scaledForRender(left), scaledForRender(right))
}
/** Frame/margin extents go to libass at the overlay's render resolution; the GL
* side scales the result back up. Identity unless RENDER_SCALE < 1. */
private fun scaledForRender(px: Int): Int = AssAtlasPipelineConfig.scaledForRender(px)
/** mpv's sub-ass-force-margins: anchor non-positioned events to the visible frame. */
fun setUseMargins(use: Boolean) {
useMargins = use
@@ -383,4 +390,16 @@ class AssHandler(
*/
private val Size.isValid
get() = width > 0 && height > 0
companion object {
/**
* Sets the global libass overlay render scale (fraction of the surface resolution; 1.0 = no
* downscale). Cheaper raster for the render-bound tail on weak GPUs, at some sharpness. Set
* before/at playback init — [AssAtlasPipelineConfig.scaledForRender] reads it on the next
* frame-size apply (overlay surface create / size change), so it takes effect from playback.
*/
fun setRenderScale(scale: Float) {
AssAtlasPipelineConfig.renderScale = scale.coerceIn(0.2f, 1.0f)
}
}
}
@@ -6,6 +6,7 @@ import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES20
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Process
@@ -17,18 +18,20 @@ import androidx.media3.common.util.GlUtil
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import com.edde746.plezy.libass.AssAtlasFrame
import com.edde746.plezy.libass.AssFrameTimestamps
import com.edde746.plezy.libass.media.AssHandler
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.LockSupport
/**
* Atlas-rendering pipeline behind [AssSubtitleSurfaceView].
* Runs libass on its own [HandlerThread] into a packed
* ALPHA_8 texture atlas plus a single vertex stream, and a GL thread that uploads
* both and issues one `glDrawArrays` per frame. Each swap is pinned to the video's
* target release time via [EGLExt.eglPresentationTimeANDROID]: SurfaceFlinger holds
* the buffer and composes it on the same vsync as the corresponding video frame.
* both and issues one `glDrawArrays` per frame. Each timed swap is pinned to the
* corresponding video frame via [EGLExt.eglPresentationTimeANDROID].
*/
@UnstableApi
internal object AssAtlasPipelineConfig {
@@ -67,9 +70,32 @@ internal object AssAtlasPipelineConfig {
* core's ~35-50ms renders it converts near-misses into on-time latches.
*/
internal const val SPECULATION_ENABLED = true
/**
* Disabled by default: queueing subtitles one predicted video frame ahead made
* Android playback often look one frame early. Keep the estimator behind a flag
* in case a device-specific compositor path later proves it needs this again.
*/
internal const val COMPOSITOR_PHASE_LEAD_ENABLED = false
/**
* Internal raster resolution for the libass overlay, as a fraction of the
* physical surface (1.0 = full, off). Lowering it shrinks the libass frame so
* heavy/animated signs rasterize over fewer pixels (raster cost ∝ area; layout
* cost unchanged) and the GL upscales the result to the full surface — a
* quality-for-throughput trade for render-bound low-end TVs. Applied
* consistently to the libass frame size, the mpv-style margins ([AssHandler])
* and the GL `u_SurfaceSize` ([AtlasRenderer]); at 1.0 it is an exact no-op.
* Runtime-settable from the user's "Render Resolution" subtitle setting (Android exposes
* Full / ¾ / ½ / ⅓ / ¼) via [AssHandler.setRenderScale]; 1.0 = exact no-op (the default).
*/
@Volatile
internal var renderScale = 1.0f
/** Scales a physical-pixel extent to the libass render resolution; identity at 1.0. */
internal fun scaledForRender(px: Int): Int = if (renderScale == 1.0f) px else Math.round(px * renderScale)
}
/** Payload handed from the libass worker to the GL thread. */
internal class AtlasPayload(
val slotIndex: Int,
val atlasBuf: ByteBuffer,
@@ -77,9 +103,38 @@ internal class AtlasPayload(
var frame: AssAtlasFrame,
var presentationTimeUs: Long,
var releaseTimeNs: Long,
/** Bumped on every content-changing render; lets the GL thread tell "same slot,
* new content" apart from "same slot, same content" when deciding to re-upload. */
var contentSeq: Long = 0L
var sourcePresentationTimeUs: Long = presentationTimeUs,
var phaseLeadUs: Long = 0L,
/** Slot identity alone is not enough because libass rewrites buffers in place. */
var contentSeq: Long = 0L,
var requestSeq: Long = 0L,
var stateGeneration: Long = 0L
)
private class AtlasDrawSnapshot(
val atlasBuf: ByteBuffer,
val vertexBuf: ByteBuffer,
val frame: AssAtlasFrame,
val sourcePresentationTimeUs: Long,
val presentationTimeUs: Long,
val releaseTimeNs: Long,
val phaseLeadUs: Long,
val contentSeq: Long,
val requestSeq: Long,
val stateGeneration: Long
)
private fun AtlasPayload.snapshot(): AtlasDrawSnapshot = AtlasDrawSnapshot(
atlasBuf = atlasBuf,
vertexBuf = vertexBuf,
frame = frame,
sourcePresentationTimeUs = sourcePresentationTimeUs,
presentationTimeUs = presentationTimeUs,
releaseTimeNs = releaseTimeNs,
phaseLeadUs = phaseLeadUs,
contentSeq = contentSeq,
requestSeq = requestSeq,
stateGeneration = stateGeneration
)
/** Payload slots plus the atlas dims their buffers were sized for. */
@@ -99,12 +154,23 @@ internal class AssAtlasPipeline(
surface: Surface,
width: Int,
height: Int,
assHandler: AssHandler,
lowRamDevice: Boolean = false
private val assHandler: AssHandler,
lowRamDevice: Boolean = false,
refreshRateHz: Float = 60f
) {
private val surfaceWidth = width
private val surfaceHeight = height
/** The calibrator needs the final pre-swap point, and this must survive surface recreation. */
var preSwapProbe: ((releaseTimeNs: Long) -> Unit)?
get() = glThread.preSwapProbe
set(value) {
glThread.preSwapProbe = value
}
// Present-error buckets need to stay meaningful across display modes.
private val vsyncNs = (1_000_000_000.0 / (if (refreshRateHz >= 1f) refreshRateHz else 60f)).toLong()
// 3 slots give the render-ahead engine a writable target while one slot is
// posted and another is in GL's hands; low-RAM devices stay at 2 slots with
// speculation off (the legacy on-demand behavior, ~19 MB less in buffers).
@@ -147,6 +213,11 @@ internal class AssAtlasPipeline(
// first actual render. Confined to the libass thread after creation.
private var slots: AtlasSlots? = null
private fun rendererStateGeneration(): Long =
assHandler.render?.let {
(System.identityHashCode(it).toLong() shl 32) or (it.stateGeneration.toLong() and 0xffffffffL)
} ?: -1L
private fun acquireSlots(): AtlasSlots {
slots?.let { return it }
if (!dimsLatch.await(1, java.util.concurrent.TimeUnit.SECONDS)) {
@@ -174,14 +245,18 @@ internal class AssAtlasPipeline(
@Volatile private var glLastTakenSlot = -1
private val pendingPayload = AtomicReference<AtlasPayload?>(null)
private val latestReadyRequestSeq = AtomicLong(0L)
private val glThread = AtlasGlThread(
surface,
width,
height,
assHandler,
vsyncNs = vsyncNs,
takePending = {
pendingPayload.getAndSet(null)?.also { glLastTakenSlot = it.slotIndex }
},
latestReadyRequestSeq = { latestReadyRequestSeq.get() },
currentStateGeneration = ::rendererStateGeneration,
resolveAtlasDims = ::resolveAtlasDims
)
private val libassThread = AtlasLibassThread(
@@ -189,7 +264,9 @@ internal class AssAtlasPipeline(
acquireSlots = ::acquireSlots,
speculationEnabled = speculationEnabled,
glTakenSlot = { glLastTakenSlot },
stateGeneration = ::rendererStateGeneration,
onFrameReady = { payload ->
latestReadyRequestSeq.set(payload.requestSeq)
pendingPayload.set(payload)
glThread.triggerDraw()
}
@@ -259,14 +336,72 @@ internal class AssAtlasPipeline(
/** Speculation rounds skipped (paused, pending request, no confident cadence). */
val specSkips: Long get() = libassThread.specSkips
/** changed==0/no-output renders forced into explicit transparent swaps. */
val blankClearCount: Long get() = libassThread.blankClearCount
/** Cache-warming prefetch renders of upcoming events. */
val prefetchCount: Long get() = libassThread.prefetchCount
/** Frame requests replaced before the libass worker serviced them. */
val coalescedRequestCount: Long get() = libassThread.coalescedRequestCount
/** Completed libass results discarded because renderer state changed before handoff. */
val staleGenerationCount: Long get() = libassThread.staleGenerationCount
/** Completed overlay snapshots skipped because a newer completed request superseded them before swap. */
val supersededBeforeSwapCount: Long get() = glThread.supersededBeforeSwapCount
/** Completed overlay snapshots skipped because renderer state changed before swap. */
val staleBeforeSwapCount: Long get() = glThread.staleBeforeSwapCount
/** Worst (minimum) lead of a changed-content pinned swap vs its target release
* time, in ms; negative = the new content was queued after the video frame's
* vsync. Long.MAX_VALUE until a changed pinned swap happened. */
val minLeadChangedMs: Long get() = glThread.minLeadChangedMs
/** Current steady-state compositor phase lead, in ms. */
val phaseLeadMs: Long get() = libassThread.phaseLeadUs / 1000
/** Most recent pinned swap lead vs its target release time, in ms. */
val lastSwapLeadMs: Long get() = glThread.lastSwapLeadMs
/** Most recent pinned swap headroom when GL work started, in ms. */
val lastSwapHeadroomMs: Long get() = glThread.lastSwapHeadroomMs
/** Most recent phase-led wait before swap, in ms. */
val lastScheduledSleepMs: Long get() = glThread.lastScheduledSleepMs
/** Adaptive swap lead actually in effect (half the measured refresh), in ms. */
val swapLeadMs: Long get() = glThread.swapLeadNs / 1_000_000
/** True once the EGL frame-timestamp extension is probed and capturing. */
val presentTimingEnabled: Boolean get() = glThread.presentTimingEnabled
/** Active present-time source, or why it's off (present/comp-start/comp-latch/off:…). */
val presentSource: String get() = glThread.presentSource
/** Actual on-screen present time of the most recent measured swap minus its
* target release time, in ms (negative = presented before the video frame's
* vsync, positive = after). The frame-perfection ground truth. */
val lastPresentErrorMs: Long get() = glThread.lastPresentErrorMs
/** Largest-magnitude present error observed, in ms. */
val worstPresentErrorMs: Long get() = glThread.worstPresentErrorMs
/** Pinned swaps whose actual present time was read back from SurfaceFlinger. */
val presentMeasuredCount: Long get() = glThread.presentMeasuredCount
/** Swaps SurfaceFlinger reported as dropped/never-presented. */
val presentInvalidCount: Long get() = glThread.presentInvalidCount
/** Pending present-time reads evicted unread because the ring filled. */
val presentDroppedCount: Long get() = glThread.presentDroppedCount
/** Present-error distribution in vsync-interval units:
* [≤−1.5, (1.5,0.5), (0.5,+0.5), [+0.5,+1.5), ≥+1.5]. A clean spike in the
* middle bucket = frame-perfect; a spike at [+0.5,+1.5) = a 1-vsync late latch. */
val presentErrorHistogram: List<Long> get() = glThread.presentErrorHistogram
fun releaseAndWait() {
libassThread.releaseAndWait()
glThread.releaseAndWait()
@@ -296,6 +431,91 @@ private fun postShutdownAndWait(
/** Transport for [postShutdownAndWait] — the handler callback calls [release] then notifies. */
private class Ack(val latch: Any, val release: () -> Unit)
private class PhaseAdjustedFrame(
val sourcePtsUs: Long,
val renderPtsUs: Long,
val releaseNs: Long,
val phaseLeadUs: Long,
val releaseLeadNs: Long
)
/**
* Predicts the next video frame from the callback cadence. Media3 gives us the
* frame currently being released, but a separate subtitle SurfaceView may not latch
* in the same composition phase. Once cadence is stable, callback N renders and
* timestamps the ASS layer for predicted frame N+1.
*/
private class SubtitleFramePhaseEstimator {
private val ptsDeltasUs = LongArray(DELTA_SAMPLES)
private val releaseDeltasNs = LongArray(DELTA_SAMPLES)
private var deltaCount = 0
private var deltaIndex = 0
private var lastPtsUs = UNSET
private var lastReleaseNs = C.TIME_UNSET
@Synchronized
fun adjust(ptsUs: Long, releaseNs: Long): PhaseAdjustedFrame {
if (!AssAtlasPipelineConfig.COMPOSITOR_PHASE_LEAD_ENABLED || releaseNs == C.TIME_UNSET) {
return PhaseAdjustedFrame(ptsUs, ptsUs, releaseNs, 0L, 0L)
}
observe(ptsUs, releaseNs)
if (deltaCount < MIN_DELTA_SAMPLES) {
return PhaseAdjustedFrame(ptsUs, ptsUs, releaseNs, 0L, 0L)
}
val ptsLeadUs = median(ptsDeltasUs, deltaCount)
val releaseLeadNs = median(releaseDeltasNs, deltaCount)
return PhaseAdjustedFrame(
sourcePtsUs = ptsUs,
renderPtsUs = saturatedAdd(ptsUs, ptsLeadUs),
releaseNs = saturatedAdd(releaseNs, releaseLeadNs),
phaseLeadUs = ptsLeadUs,
releaseLeadNs = releaseLeadNs
)
}
private fun observe(ptsUs: Long, releaseNs: Long) {
val prevPtsUs = lastPtsUs
val prevReleaseNs = lastReleaseNs
lastPtsUs = ptsUs
lastReleaseNs = releaseNs
if (prevPtsUs == UNSET || prevReleaseNs == C.TIME_UNSET) return
val ptsDeltaUs = ptsUs - prevPtsUs
val releaseDeltaNs = releaseNs - prevReleaseNs
if (ptsDeltaUs <= 0 || ptsDeltaUs > MAX_DELTA_US ||
releaseDeltaNs <= 0 || releaseDeltaNs > MAX_DELTA_NS
) {
deltaCount = 0
deltaIndex = 0
return
}
ptsDeltasUs[deltaIndex] = ptsDeltaUs
releaseDeltasNs[deltaIndex] = releaseDeltaNs
deltaIndex = (deltaIndex + 1) % DELTA_SAMPLES
if (deltaCount < DELTA_SAMPLES) deltaCount++
}
private fun median(values: LongArray, count: Int): Long {
val copy = values.copyOfRange(0, count)
copy.sort()
return copy[count / 2]
}
private fun saturatedAdd(value: Long, delta: Long): Long =
if (delta > 0 && value > Long.MAX_VALUE - delta) Long.MAX_VALUE else value + delta
private companion object {
const val UNSET = Long.MIN_VALUE
const val DELTA_SAMPLES = 8
const val MIN_DELTA_SAMPLES = 4
const val MAX_DELTA_US = 250_000L
const val MAX_DELTA_NS = 250_000_000L
}
}
/**
* Runs libass off the GL thread into a packed atlas + vertex stream. Latest-wins:
* older pending renders are dropped when a newer one arrives. Slot choice, the
@@ -308,30 +528,54 @@ private class AtlasLibassThread(
private val acquireSlots: () -> AtlasSlots,
private val speculationEnabled: Boolean,
private val glTakenSlot: () -> Int,
private val stateGeneration: () -> Long,
private val onFrameReady: (AtlasPayload) -> Unit
) : HandlerThread(TAG, Process.THREAD_PRIORITY_DISPLAY) {
/** Immutable (pts, release) request — handed off through a single atomic so a
* concurrent enqueue can neither be lost by drain's consume nor torn in half.
* [enqueueNs] timestamps the handoff so drain can report how long the request
* sat behind an in-flight render (the queue-wait component of subtitle lag). */
private class PendingFrame(val ptsUs: Long, val releaseNs: Long, val enqueueNs: Long = System.nanoTime())
/** Immutable frame request — handed off through a single atomic so a concurrent
* enqueue can neither be lost by drain's consume nor torn in half. [ptsUs] is
* the libass render timestamp after any phase lead; [sourcePtsUs] is the video
* callback PTS after user subtitle delay but before the compositor lead.
* [sequence] is the selected-video-frame request identity, and [enqueueNs]
* timestamps the handoff so drain can report queue wait. */
private class PendingFrame(
val sourcePtsUs: Long,
val ptsUs: Long,
val releaseNs: Long,
val phaseLeadUs: Long,
val releaseLeadNs: Long,
val sequence: Long,
val enqueueNs: Long = System.nanoTime()
)
private lateinit var handler: Handler
private val pending = AtomicReference<PendingFrame?>(null)
private val phaseEstimator = SubtitleFramePhaseEstimator()
private val requestSeqCounter = AtomicLong(0L)
@Volatile private var lastRequestedPtsUs = UNSET
private var contentSeqCounter = 0L
// Thread-confined; created on first render so non-ASS playback never allocates.
private var engine: SpecRenderEngine? = null
private var engineStateGeneration = Long.MIN_VALUE
val specHits: Long get() = engine?.specHits ?: 0L
val specMisses: Long get() = engine?.specMisses ?: 0L
val specSkips: Long get() = engine?.specSkips ?: 0L
val blankClearCount: Long get() = engine?.blankClearCount ?: 0L
val prefetchCount: Long get() = engine?.prefetchCount ?: 0L
@Volatile var coalescedRequestCount = 0L
private set
@Volatile var staleGenerationCount = 0L
private set
@Volatile var phaseLeadUs = 0L
private set
// Telemetry; single-writer (this thread), read from the stats path.
@Volatile var renderCount = 0L
private set
@@ -382,15 +626,31 @@ private class AtlasLibassThread(
fun enqueue(presentationTimeUs: Long, releaseTimeNs: Long) {
if (!::handler.isInitialized) return
val dropped = pending.getAndSet(PendingFrame(presentationTimeUs, releaseTimeNs))
if (dropped != null && AssAtlasPipelineConfig.TIMING_LOGS) {
// A request was coalesced away — the renderer is behind by at least one
// frame. agedMs = how long the dropped request had been waiting.
Log.d(
TAG,
"drop pts=${dropped.ptsUs / 1000}ms agedMs=${(System.nanoTime() - dropped.enqueueNs) / 1_000_000} " +
"replacedBy=${presentationTimeUs / 1000}ms"
val adjusted = phaseEstimator.adjust(presentationTimeUs, releaseTimeNs)
phaseLeadUs = adjusted.phaseLeadUs
val sequence = requestSeqCounter.incrementAndGet()
val dropped = pending.getAndSet(
PendingFrame(
sourcePtsUs = adjusted.sourcePtsUs,
ptsUs = adjusted.renderPtsUs,
releaseNs = adjusted.releaseNs,
phaseLeadUs = adjusted.phaseLeadUs,
releaseLeadNs = adjusted.releaseLeadNs,
sequence = sequence
)
)
if (dropped != null) {
coalescedRequestCount++
if (AssAtlasPipelineConfig.TIMING_LOGS) {
// A request was coalesced away — the renderer is behind by at least one
// frame. agedMs = how long the dropped request had been waiting.
Log.d(
TAG,
"drop seq=${dropped.sequence} src=${dropped.sourcePtsUs / 1000}ms pts=${dropped.ptsUs / 1000}ms " +
"agedMs=${(System.nanoTime() - dropped.enqueueNs) / 1_000_000} " +
"replacedBySeq=$sequence replacedBySrc=${presentationTimeUs / 1000}ms replacedByPts=${adjusted.renderPtsUs / 1000}ms"
)
}
}
handler.removeMessages(MSG_RENDER)
handler.sendEmptyMessage(MSG_RENDER)
@@ -405,8 +665,10 @@ private class AtlasLibassThread(
enqueue(pts, C.TIME_UNSET)
}
private fun ensureEngine(slots: AtlasSlots): SpecRenderEngine {
engine?.let { return it }
private fun ensureEngine(slots: AtlasSlots, generation: Long): SpecRenderEngine {
engine?.takeIf { engineStateGeneration == generation }?.let { return it }
engineStateGeneration = generation
unchangedStreak = 0
return SpecRenderEngine(
slotCount = slots.payloads.size,
speculationEnabled = speculationEnabled,
@@ -414,11 +676,7 @@ private class AtlasLibassThread(
// Renderer identity in the high bits + its state generation in the low bits:
// a recreated renderer (media item transition) can never alias a stale
// speculation, even if the new generation counter happens to match.
stateGeneration = {
assHandler.render?.let {
(System.identityHashCode(it).toLong() shl 32) or (it.stateGeneration.toLong() and 0xffffffffL)
} ?: -1L
},
stateGeneration = stateGeneration,
glTakenSlot = glTakenSlot,
debugLog = if (AssAtlasPipelineConfig.TIMING_LOGS) ({ msg -> Log.d(TAG, msg) }) else null
).also { engine = it }
@@ -453,9 +711,10 @@ private class AtlasLibassThread(
private fun drainAndRender() {
val request = pending.getAndSet(null) ?: return
val sourcePts = request.sourcePtsUs
val pts = request.ptsUs
val releaseNs = request.releaseNs
lastRequestedPtsUs = pts
lastRequestedPtsUs = sourcePts
val tDrain = System.nanoTime()
// How long the request sat in the handoff (behind an in-flight on-demand or
// speculative render) — the queue-wait component of any subtitle lag.
@@ -464,27 +723,41 @@ private class AtlasLibassThread(
// keeps the slot buffers unallocated for non-ASS playback.
if (assHandler.render == null) return
val slots = acquireSlots()
val engine = ensureEngine(slots)
val generation = stateGeneration()
val engine = ensureEngine(slots, generation)
val pinned = releaseNs != C.TIME_UNSET
// Budget left until the video frame's vsync when we START servicing.
val budgetMs = if (pinned) (releaseNs - tDrain) / 1_000_000 else -1L
when (val outcome = engine.service(pts, pinned)) {
is SpecRenderEngine.Outcome.Post -> {
if (stateGeneration() != generation) {
staleGenerationCount++
engineStateGeneration = Long.MIN_VALUE
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(TAG, "stale-render req=${request.sequence} pts=${pts / 1000}ms")
}
return
}
val payload = slots.payloads[outcome.slot]
if (outcome.newContent) {
payload.frame = outcome.frame
payload.contentSeq = ++contentSeqCounter
}
payload.sourcePresentationTimeUs = sourcePts
payload.presentationTimeUs = pts
payload.releaseTimeNs = releaseNs
payload.phaseLeadUs = request.phaseLeadUs
payload.requestSeq = request.sequence
payload.stateGeneration = generation
onFrameReady(payload)
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(
TAG,
"render pts=${pts / 1000}ms seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
"render req=${request.sequence} src=${sourcePts / 1000}ms pts=${pts / 1000}ms phaseLeadMs=${request.phaseLeadUs / 1000} " +
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
"specHit=${outcome.specHit} changed=${payload.frame.changed} quads=${payload.frame.quadCount} " +
"specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " +
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated}"
)
}
@@ -498,6 +771,7 @@ private class AtlasLibassThread(
// Pre-render the predicted next frame in the dead time between requests so the
// next service is (usually) a GL-only hit. Never delays a waiting request.
if (stateGeneration() != generation) return
engine.speculateAfter(pts, pinned, hasPending = pending.get() != null)?.let { write ->
val payload = slots.payloads[write.slot]
payload.frame = write.frame
@@ -506,7 +780,7 @@ private class AtlasLibassThread(
Log.d(
TAG,
"spec after=${pts / 1000}ms seq=${payload.contentSeq} libassMs=$lastLibassMs " +
"lockWaitMs=${assHandler.render?.lastLockWaitMs} slot=${write.slot} quads=${write.frame.quadCount}"
"lockWaitMs=${assHandler.render?.lastLockWaitMs} slot=${write.slot} output=${write.frame.hasOutput} quads=${write.frame.quadCount}"
)
}
}
@@ -545,7 +819,7 @@ private class AtlasLibassThread(
val now = System.nanoTime()
if (now - lastPrefetchNs < PREFETCH_COOLDOWN_NS) return
val track = assHandler.track ?: return
val nowMs = ptsUs / 1000
val nowMs = Math.floorDiv(ptsUs, 1_000L)
// Events closer than MIN_AHEAD are the regular per-frame path's business;
// beyond HORIZON the warmed bitmaps may be evicted before they're needed.
val targetMs = track.nextEventStartMs(nowMs + PREFETCH_MIN_AHEAD_MS)
@@ -619,9 +893,8 @@ private class AtlasLibassThread(
/**
* Owns the EGL surface, uploads the atlas + vertex stream and issues a single
* `glDrawArrays` per frame. Swaps immediately with the swap pinned to the video's
* target release time via [EGLExt.eglPresentationTimeANDROID]; SurfaceFlinger
* holds the buffer until then, so the thread is never blocked waiting for a vsync.
* `glDrawArrays` per frame. Timed swaps are queued close to the target video
* release time and stamped via [EGLExt.eglPresentationTimeANDROID].
*/
@UnstableApi
private class AtlasGlThread(
@@ -629,10 +902,57 @@ private class AtlasGlThread(
@Volatile private var width: Int,
@Volatile private var height: Int,
private val assHandler: AssHandler,
private val vsyncNs: Long,
private val takePending: () -> AtlasPayload?,
private val latestReadyRequestSeq: () -> Long,
private val currentStateGeneration: () -> Long,
private val resolveAtlasDims: (maxTextureSize: Int) -> Pair<Int, Int>
) : HandlerThread(TAG, Process.THREAD_PRIORITY_DISPLAY) {
// Calibration hook invoked just before each pinned eglSwapBuffers (see AssAtlasPipeline).
@Volatile var preSwapProbe: ((releaseTimeNs: Long) -> Unit)? = null
// Swap lead = half the refresh interval, clamped. Measured live from the actual
// gap between video-frame release targets rather than display.refreshRate, which
// can still read the pre-switch rate when a pre-open mode change hasn't settled
// (the bug that pinned the lead at ~8 ms while the panel was really at 24 Hz).
private val releaseDeltasNs = LongArray(8)
private var releaseDeltaCount = 0
private var releaseDeltaIndex = 0
private var lastCadenceReleaseNs = C.TIME_UNSET
// Median gap between successive video-frame release targets (≈ one refresh at
// matched cadence). Drives both the swap lead and the present offset.
@Volatile private var measuredIntervalNs: Long = vsyncNs
@Volatile var swapLeadNs: Long =
(vsyncNs / 2).coerceIn(SCHEDULED_SWAP_LEAD_MIN_NS, SCHEDULED_SWAP_LEAD_MAX_NS)
private set
private fun updateSwapLead(releaseNs: Long) {
val prev = lastCadenceReleaseNs
lastCadenceReleaseNs = releaseNs
if (prev == C.TIME_UNSET) return
val d = releaseNs - prev
if (d <= 0 || d > MAX_RELEASE_DELTA_NS) {
releaseDeltaCount = 0
releaseDeltaIndex = 0
return
}
releaseDeltasNs[releaseDeltaIndex] = d
releaseDeltaIndex = (releaseDeltaIndex + 1) % releaseDeltasNs.size
if (releaseDeltaCount < releaseDeltasNs.size) releaseDeltaCount++
if (releaseDeltaCount >= 4) {
val copy = releaseDeltasNs.copyOf(releaseDeltaCount)
copy.sort()
measuredIntervalNs = copy[releaseDeltaCount / 2]
swapLeadNs = (measuredIntervalNs / 2).coerceIn(
SCHEDULED_SWAP_LEAD_MIN_NS, SCHEDULED_SWAP_LEAD_MAX_NS
)
}
}
private lateinit var handler: Handler
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
@@ -658,6 +978,59 @@ private class AtlasGlThread(
@Volatile var minLeadChangedMs = Long.MAX_VALUE
private set
@Volatile var lastSwapLeadMs = 0L
private set
@Volatile var lastSwapHeadroomMs = 0L
private set
@Volatile var lastScheduledSleepMs = 0L
private set
@Volatile var supersededBeforeSwapCount = 0L
private set
@Volatile var staleBeforeSwapCount = 0L
private set
// --- Present-time ground truth (EGL_ANDROID_get_frame_timestamps) ---
// The actual on-screen present time is reported a few swaps after eglSwapBuffers,
// so swapped frame ids are recorded here and resolved lazily. Confined to this
// (single) GL thread; telemetry fields are @Volatile for the stats reader.
@Volatile var presentTimingEnabled = false
private set
/** Which timestamp source the probe settled on, or why it's off (for diagnosis). */
@Volatile var presentSource = "off:uninit"
private set
private val ptFrameId = LongArray(PRESENT_RING)
private val ptReleaseNs = LongArray(PRESENT_RING)
private var ptHead = 0
private var ptCount = 0
@Volatile var lastPresentErrorMs = 0L
private set
@Volatile var worstPresentErrorMs = 0L
private set
@Volatile var presentMeasuredCount = 0L
private set
@Volatile var presentInvalidCount = 0L
private set
@Volatile var presentDroppedCount = 0L
private set
private val ptBuckets = LongArray(PRESENT_BUCKETS)
val presentErrorHistogram: List<Long> get() = ptBuckets.toList()
// Tracks renderer-state generation so transient seek swaps don't permanently
// corrupt the worst-case lead/present signals.
private var lastGenerationSeen = Long.MIN_VALUE
override fun start() {
super.start()
handler = Handler(looper) { msg ->
@@ -717,11 +1090,34 @@ private class AtlasGlThread(
val (atlasW, atlasH) = resolveAtlasDims(maxTexture[0])
renderer.allocateAtlasTexture(atlasW, atlasH)
sizeChanged(width, height)
initPresentTiming()
} catch (e: GlUtil.GlException) {
Log.e(TAG, "Failed to initialize EGL", e)
}
}
/** Probes the EGL frame-timestamp extension on the freshly-current surface.
* EGL_ANDROID_get_frame_timestamps exists from Android 8.0 (entry points are
* resolved at runtime via eglGetProcAddress), so gate at O and let the native
* probe disable itself where the driver/emulator reports it unsupported — that
* "unsupported" result is itself the signal that present time can't be measured. */
private fun initPresentTiming() {
ptHead = 0
ptCount = 0
val status = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
AssFrameTimestamps.ERR_UNSUPPORTED
} else {
try {
AssFrameTimestamps.nativeInit()
} catch (t: Throwable) {
Log.w(TAG, "frame-timestamp init failed; present timing disabled", t)
AssFrameTimestamps.ERR_NO_PROC
}
}
presentTimingEnabled = status >= 0
presentSource = AssFrameTimestamps.sourceLabel(status)
}
private fun sizeChanged(width: Int, height: Int) {
renderer.onSurfaceChanged(width, height)
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
@@ -732,62 +1128,177 @@ private class AtlasGlThread(
private fun drawAndSwap() {
if (eglDisplay == EGL14.EGL_NO_DISPLAY) return
drainPresentTimestamps()
val payload = takePending() ?: return
// Render immediately (GL commands queue on the GPU). Re-upload only when the
// slot's content actually changed — identity alone is not enough because the
// libass side rewrites slot buffers in place (contentSeq tracks the rewrites).
val t0 = System.nanoTime()
val reuse = payload === lastUploadedPayload && payload.contentSeq == lastUploadedSeq
renderer.onDrawFrame(payload, reuseUploads = reuse)
val snapshot = payload.snapshot()
val reuse = payload === lastUploadedPayload && snapshot.contentSeq == lastUploadedSeq
renderer.onDrawFrame(snapshot, reuseUploads = reuse)
lastUploadedPayload = payload
lastUploadedSeq = payload.contentSeq
lastUploadedSeq = snapshot.contentSeq
val t1 = System.nanoTime()
// Swap immediately with the presentation time set: SurfaceFlinger holds the
// queued buffer until the video frame's target release time, so the subtitle
// can never appear early, and the GL thread is free again within a couple of
// milliseconds. Sleeping here until near the target vsync (as a removed
// TextureView path once required) made this thread blind for almost a whole
// frame interval — the single-slot latest-wins handoff would then drop an
// intermediate subtitle state (e.g. the transition to blank), showing the
// previous state one frame too long.
if (payload.releaseTimeNs != C.TIME_UNSET) {
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, payload.releaseTimeNs)
// The overlay needs latch margin, but a drawn pinned payload still belongs to
// its video frame even if the next frame becomes ready while we wait.
val pinned = snapshot.releaseTimeNs != C.TIME_UNSET
val contentChanged = snapshot.contentSeq != lastSwappedSeq
if (pinned) updateSwapLead(snapshot.releaseTimeNs)
val presentTarget = snapshot.releaseTimeNs
val scheduledSleepMs = if (pinned) sleepUntilScheduledSwap(presentTarget) else 0L
val latestReadySeq = latestReadyRequestSeq()
val currentGeneration = currentStateGeneration()
if (currentGeneration != lastGenerationSeen) {
// Seek/track/size churn should not poison the worst-case lead signal.
lastGenerationSeen = currentGeneration
minLeadChangedMs = Long.MAX_VALUE
}
val stale = snapshot.stateGeneration != currentGeneration
// A newer ready request can be for the next video frame, while this buffer is
// still the only correct content for its own target frame.
val superseded = !pinned && snapshot.requestSeq < latestReadySeq
if (stale || superseded) {
if (stale) staleBeforeSwapCount++
if (superseded) supersededBeforeSwapCount++
lastScheduledSleepMs = scheduledSleepMs
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(
TAG,
"skip-before-swap req=${snapshot.requestSeq} latest=$latestReadySeq " +
"stale=$stale gen=${snapshot.stateGeneration}/$currentGeneration " +
"src=${snapshot.sourcePresentationTimeUs / 1000}ms pts=${snapshot.presentationTimeUs / 1000}ms " +
"sleepMs=$scheduledSleepMs pinned=$pinned"
)
}
return
}
if (pinned) {
// Keep the empty probe transaction out from between the presentation
// timestamp and the swap it is meant to measure.
preSwapProbe?.invoke(presentTarget)
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, presentTarget)
}
// SurfaceFlinger only knows the frame id before swap and the present time later.
val frameId = if (pinned && presentTimingEnabled) {
try {
AssFrameTimestamps.nativeGetNextFrameId()
} catch (t: Throwable) {
presentTimingEnabled = false
-1L
}
} else {
-1L
}
EGL14.eglSwapBuffers(eglDisplay, eglSurface)
val t2 = System.nanoTime()
if (payload.releaseTimeNs != C.TIME_UNSET) {
if (frameId >= 0) recordSwappedFrame(frameId, presentTarget)
var headroomMs = -1L
var leadMs = -1L
if (pinned) {
swapCount++
val lateNs = t2 - payload.releaseTimeNs
headroomMs = (presentTarget - t0) / 1_000_000
val lateNs = t2 - presentTarget
leadMs = -lateNs / 1_000_000
lastSwapHeadroomMs = headroomMs
lastSwapLeadMs = leadMs
lastScheduledSleepMs = scheduledSleepMs
if (lateNs > LATE_THRESHOLD_NS) {
lateSwapCount++
val lateMs = lateNs / 1_000_000
if (lateMs > maxLateMs) maxLateMs = lateMs
}
// Lead of changed-content swaps is the frame-perfection signal: ≥ 0 means
// the new subtitle content reached the queue before the video frame's vsync.
if (payload.contentSeq != lastSwappedSeq) {
val leadMs = -lateNs / 1_000_000
if (contentChanged) {
if (leadMs < minLeadChangedMs) minLeadChangedMs = leadMs
}
if (swapCount % SYNC_LOG_INTERVAL_SWAPS == 0L) {
Log.i(
TAG,
"[ASS-sync] swaps=$swapCount late=$lateSwapCount maxLateMs=$maxLateMs " +
"minLeadChangedMs=${if (minLeadChangedMs == Long.MAX_VALUE) "n/a" else minLeadChangedMs} " +
"present=$presentSource presentErrMs=$lastPresentErrorMs worstPresentMs=$worstPresentErrorMs " +
"presentHist=${ptBuckets.joinToString(",")} measured=$presentMeasuredCount " +
"presentInvalid=$presentInvalidCount presentDropped=$presentDroppedCount " +
"src=${snapshot.sourcePresentationTimeUs / 1000}ms pts=${snapshot.presentationTimeUs / 1000}ms " +
"phaseLeadMs=${snapshot.phaseLeadUs / 1000} sleepMs=$scheduledSleepMs headroomMs=$headroomMs leadMs=$leadMs " +
"req=${snapshot.requestSeq} seq=${snapshot.contentSeq} superseded=$supersededBeforeSwapCount stale=$staleBeforeSwapCount " +
"changed=$contentChanged reused=$reuse"
)
}
}
lastSwappedSeq = payload.contentSeq
lastSwappedSeq = snapshot.contentSeq
if (AssAtlasPipelineConfig.TIMING_LOGS) {
val pinned = payload.releaseTimeNs != C.TIME_UNSET
// headroomMs: slack before the target vsync when GL STARTED; leadMs: slack
// when the buffer was actually queued (negative = queued after the vsync).
val headroomMs = if (pinned) (payload.releaseTimeNs - t0) / 1_000_000 else -1L
val leadMs = if (pinned) (payload.releaseTimeNs - t2) / 1_000_000 else -1L
Log.d(
TAG,
"swap pts=${payload.presentationTimeUs / 1000}ms seq=${payload.contentSeq} " +
"quads=${payload.frame.quadCount} reused=$reuse drawMs=${(t1 - t0) / 1_000_000} " +
"swapMs=${(t2 - t1) / 1_000_000} headroomMs=$headroomMs leadMs=$leadMs pinned=$pinned"
"swap src=${snapshot.sourcePresentationTimeUs / 1000}ms pts=${snapshot.presentationTimeUs / 1000}ms " +
"phaseLeadMs=${snapshot.phaseLeadUs / 1000} req=${snapshot.requestSeq} seq=${snapshot.contentSeq} " +
"quads=${snapshot.frame.quadCount} reused=$reuse drawMs=${(t1 - t0) / 1_000_000} " +
"sleepMs=$scheduledSleepMs swapMs=${(t2 - t1) / 1_000_000} headroomMs=$headroomMs leadMs=$leadMs pinned=$pinned"
)
}
}
private fun sleepUntilScheduledSwap(releaseTimeNs: Long): Long {
val startNs = System.nanoTime()
val wakeNs = releaseTimeNs - swapLeadNs
var remainingNs = wakeNs - startNs
while (remainingNs > SCHEDULED_SWAP_SPIN_NS && !Thread.currentThread().isInterrupted) {
LockSupport.parkNanos(remainingNs)
remainingNs = wakeNs - System.nanoTime()
}
return ((System.nanoTime() - startNs).coerceAtLeast(0L)) / 1_000_000
}
private fun recordSwappedFrame(frameId: Long, releaseNs: Long) {
if (ptCount == PRESENT_RING) {
ptHead = (ptHead + 1) % PRESENT_RING
ptCount--
presentDroppedCount++
}
val tail = (ptHead + ptCount) % PRESENT_RING
ptFrameId[tail] = frameId
ptReleaseNs[tail] = releaseNs
ptCount++
}
private fun drainPresentTimestamps() {
if (!presentTimingEnabled) return
while (ptCount > 0) {
val idx = ptHead
val present = try {
AssFrameTimestamps.nativeGetDisplayPresentTime(ptFrameId[idx])
} catch (t: Throwable) {
presentTimingEnabled = false
return
}
if (present == AssFrameTimestamps.PENDING) return
ptHead = (ptHead + 1) % PRESENT_RING
ptCount--
if (present <= 0L) {
presentInvalidCount++
continue
}
recordPresentError(present - ptReleaseNs[idx])
}
}
private fun recordPresentError(errorNs: Long) {
presentMeasuredCount++
val errorMs = errorNs / 1_000_000
lastPresentErrorMs = errorMs
if (Math.abs(errorMs) > Math.abs(worstPresentErrorMs)) worstPresentErrorMs = errorMs
val frac = errorNs.toDouble() / vsyncNs.toDouble()
val bucket = when {
frac < -1.5 -> 0
frac < -0.5 -> 1
frac < 0.5 -> 2
frac < 1.5 -> 3
else -> 4
}
ptBuckets[bucket]++
}
private fun releaseEgl() {
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
try {
@@ -810,6 +1321,15 @@ private class AtlasGlThread(
private const val MSG_DRAW = 2
private const val MSG_SIZE_CHANGED = 3
private const val MSG_RELEASE = 4
private const val SYNC_LOG_INTERVAL_SWAPS = 120L
// Missing SurfaceFlinger's latch deadline costs a full refresh, so keep the
// margin proportional to the active cadence.
private const val SCHEDULED_SWAP_LEAD_MIN_NS = 6_000_000L
private const val SCHEDULED_SWAP_LEAD_MAX_NS = 18_000_000L
private const val SCHEDULED_SWAP_SPIN_NS = 200_000L
private const val MAX_RELEASE_DELTA_NS = 250_000_000L
private const val PRESENT_RING = 16
private const val PRESENT_BUCKETS = 5
/**
* Swaps finishing this far past the target release time arrived after the
@@ -850,8 +1370,9 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
varying vec4 v_Color;
uniform sampler2D u_Texture;
void main() {
float alpha = texture2D(u_Texture, v_TexCoord).a;
gl_FragColor = v_Color * alpha;
float mask = texture2D(u_Texture, v_TexCoord).a;
float alpha = v_Color.a * mask;
gl_FragColor = vec4(v_Color.rgb * alpha, alpha);
}
""".trimIndent()
@@ -915,17 +1436,30 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1)
GLES20.glEnable(GLES20.GL_BLEND)
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
// Store the translucent SurfaceView buffer premultiplied, matching Android
// layer composition and avoiding a second alpha multiply on libass masks.
GLES20.glBlendFuncSeparate(
GLES20.GL_ONE,
GLES20.GL_ONE_MINUS_SRC_ALPHA,
GLES20.GL_ONE,
GLES20.GL_ONE_MINUS_SRC_ALPHA
)
}
fun onSurfaceChanged(width: Int, height: Int) {
surfaceSize = Size(width, height)
assHandler.render?.setFrameSize(width, height)
// Render libass at RENDER_SCALE of the physical surface; the viewport stays
// full-size so the GL upscales the lower-res atlas to fill the surface. The
// u_SurfaceSize denominator must match the (scaled) libass frame so vertices,
// baked in frame-space, still map across the whole surface.
val frameW = AssAtlasPipelineConfig.scaledForRender(width)
val frameH = AssAtlasPipelineConfig.scaledForRender(height)
assHandler.render?.setFrameSize(frameW, frameH)
GLES20.glViewport(0, 0, width, height)
GLES20.glUniform2f(uSurfaceSize, width.toFloat(), height.toFloat())
GLES20.glUniform2f(uSurfaceSize, frameW.toFloat(), frameH.toFloat())
}
fun onDrawFrame(payload: AtlasPayload, reuseUploads: Boolean) {
fun onDrawFrame(payload: AtlasDrawSnapshot, reuseUploads: Boolean) {
GlUtil.clearFocusedBuffers()
val frame = payload.frame
@@ -23,6 +23,7 @@ class AssSubtitleSurfaceView(
SurfaceHolder.Callback {
private var pipeline: AssAtlasPipeline? = null
private var preSwapProbe: ((Long) -> Unit)? = null
init {
setZOrderMediaOverlay(true)
@@ -30,6 +31,16 @@ class AssSubtitleSurfaceView(
holder.addCallback(this)
}
/**
* Hook invoked on the GL thread just before each pinned overlay swap, with that swap's target
* releaseTimeNs. Set by the app's latency calibrator; cleared (null) once it converges.
* Survives pipeline recreation (re-applied in [surfaceCreated]).
*/
fun setPreSwapProbe(hook: ((Long) -> Unit)?) {
preSwapProbe = hook
pipeline?.preSwapProbe = hook
}
fun requestRender(presentationTimeUs: Long, releaseTimeNs: Long) {
pipeline?.requestRender(presentationTimeUs, releaseTimeNs)
}
@@ -75,20 +86,83 @@ class AssSubtitleSurfaceView(
/** Speculation rounds skipped (paused, pending request, no confident cadence). */
val specSkips: Long get() = pipeline?.specSkips ?: 0L
/** changed==0/no-output renders forced into explicit transparent swaps. */
val blankClearCount: Long get() = pipeline?.blankClearCount ?: 0L
/** Cache-warming prefetch renders of upcoming events. */
val prefetchCount: Long get() = pipeline?.prefetchCount ?: 0L
/** Frame requests replaced before the libass worker serviced them. */
val coalescedRequestCount: Long get() = pipeline?.coalescedRequestCount ?: 0L
/** Completed libass results discarded because renderer state changed before handoff. */
val staleGenerationCount: Long get() = pipeline?.staleGenerationCount ?: 0L
/** Completed overlay snapshots skipped because newer completed content superseded them. */
val supersededBeforeSwapCount: Long get() = pipeline?.supersededBeforeSwapCount ?: 0L
/** Completed overlay snapshots skipped because renderer state changed before swap. */
val staleBeforeSwapCount: Long get() = pipeline?.staleBeforeSwapCount ?: 0L
/** Minimum lead of changed-content pinned swaps vs the video frame's release
* time, in ms (negative = late); null until one happened. */
val minLeadChangedMs: Long? get() = pipeline?.minLeadChangedMs?.takeIf { it != Long.MAX_VALUE }
/** Current compositor phase lead applied by the atlas pipeline, in ms. */
val phaseLeadMs: Long get() = pipeline?.phaseLeadMs ?: 0L
/** Most recent pinned swap lead vs its target release time, in ms. */
val lastSwapLeadMs: Long get() = pipeline?.lastSwapLeadMs ?: 0L
/** Most recent pinned swap headroom when GL work started, in ms. */
val lastSwapHeadroomMs: Long get() = pipeline?.lastSwapHeadroomMs ?: 0L
/** Most recent phase-led wait before swap, in ms. */
val lastScheduledSleepMs: Long get() = pipeline?.lastScheduledSleepMs ?: 0L
/** Adaptive swap lead actually in effect (half the measured refresh interval), in ms. */
val swapLeadMs: Long get() = pipeline?.swapLeadMs ?: 0L
/** True once the EGL frame-timestamp extension is probed and capturing present
* times (API 26+, real device). False on the emulator / pre-26 / no driver support. */
val presentTimingEnabled: Boolean get() = pipeline?.presentTimingEnabled ?: false
/** Active present-time source, or why it's off (present/comp-start/comp-latch/off:…). */
val presentSource: String get() = pipeline?.presentSource ?: "off:no-pipeline"
/** Actual on-screen present time of the most recent measured swap minus its
* target release time, in ms (negative = before the video frame's vsync). The
* frame-perfection ground truth; null until a swap has been measured. */
val lastPresentErrorMs: Long? get() = pipeline?.lastPresentErrorMs.takeIf { presentMeasuredCount > 0 }
/** Largest-magnitude present error observed, in ms; null until measured. */
val worstPresentErrorMs: Long? get() = pipeline?.worstPresentErrorMs.takeIf { presentMeasuredCount > 0 }
/** Pinned swaps whose actual present time was read back from SurfaceFlinger. */
val presentMeasuredCount: Long get() = pipeline?.presentMeasuredCount ?: 0L
/** Swaps SurfaceFlinger reported as dropped/never-presented. */
val presentInvalidCount: Long get() = pipeline?.presentInvalidCount ?: 0L
/** Pending present-time reads evicted unread because the ring filled. */
val presentDroppedCount: Long get() = pipeline?.presentDroppedCount ?: 0L
/** Present-error distribution in vsync-interval units:
* [≤−1.5, (1.5,0.5), (0.5,+0.5), [+0.5,+1.5), ≥+1.5]. Middle = frame-perfect. */
val presentErrorHistogram: List<Long> get() = pipeline?.presentErrorHistogram ?: emptyList()
override fun surfaceCreated(holder: SurfaceHolder) {
val rect = holder.surfaceFrame
assHandler.setOverlaySurfaceSize(rect.width(), rect.height())
val lowRam = (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)
?.isLowRamDevice ?: false
pipeline = AssAtlasPipeline(holder.surface, rect.width(), rect.height(), assHandler, lowRam)
.also { it.start() }
// Display refresh drives the present-error histogram's vsync-relative buckets.
val refreshRate = display?.refreshRate?.takeIf { it >= 1f } ?: 60f
pipeline = AssAtlasPipeline(holder.surface, rect.width(), rect.height(), assHandler, lowRam, refreshRate)
.also {
it.preSwapProbe = preSwapProbe
it.start()
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
@@ -1,7 +1,6 @@
package com.edde746.plezy.libass.media.widget
import com.edde746.plezy.libass.AssAtlasFrame
import kotlin.math.abs
/**
* Decision core of the speculative render-ahead pipeline.
@@ -99,6 +98,10 @@ internal class SpecRenderEngine(
var specSkips = 0L
private set
@Volatile
var blankClearCount = 0L
private set
/**
* Services a render request for [ptsUs]. [pinned] is false for invalidate
* repaints (paused margin changes etc.), which never feed the cadence
@@ -107,10 +110,11 @@ internal class SpecRenderEngine(
fun service(ptsUs: Long, pinned: Boolean): Outcome {
if (pinned) updateDeltaEstimator(ptsUs)
val ptsMs = toLibassMs(ptsUs)
if (specPtsUs != UNSET) {
val eps = epsilonUs()
val specPtsMs = toLibassMs(specPtsUs)
val genNow = stateGeneration()
val hit = genNow == specGen && eps > 0 && abs(ptsUs - specPtsUs) <= eps
val hit = genNow == specGen && ptsMs == specPtsMs
val slot = if (specIsLibassLast) libassLastSlot else specSlot
val frame = libassLastFrame
val specPts = specPtsUs
@@ -118,23 +122,32 @@ internal class SpecRenderEngine(
if (hit && slot >= 0 && frame != null) {
specHits++
lastPostedSlot = slot
debugLog?.invoke("hit pts=${ptsUs / 1000}ms spec=${specPts / 1000}ms d=${(ptsUs - specPts) / 1000}ms slot=$slot")
debugLog?.invoke(
"hit pts=${ptsMs}ms spec=${specPtsMs}ms dUs=${ptsUs - specPts} slot=$slot"
)
return Outcome.Post(slot, frame, newContent = false, specHit = true)
}
specMisses++
debugLog?.invoke(
"miss pts=${ptsUs / 1000}ms spec=${specPts / 1000}ms d=${(ptsUs - specPts) / 1000}ms eps=${eps / 1000}ms " +
"miss pts=${ptsMs}ms spec=${specPtsMs}ms dUs=${ptsUs - specPts} " +
"gen=${if (genNow == specGen) "ok" else "CHANGED"} slot=$slot frame=${frame != null}"
)
} else {
debugLog?.invoke("no-spec pts=${ptsUs / 1000}ms")
debugLog?.invoke("no-spec pts=${ptsMs}ms")
}
// On-demand render. Preferring libassLastSlot as the target makes changed == 0
// unambiguous: the buffers were untouched and already hold the right content.
// On-demand render. For changed == 0 with visible output, the buffers were
// untouched and libassLastSlot already holds the right content.
val target = renderTargetSlot() ?: return Outcome.Skip
val frame = renderAt(ptsUs / 1000, target) ?: return Outcome.Skip
val frame = renderAt(ptsMs, target) ?: return Outcome.Skip
if (frame.changed == 0) {
if (isImplicitBlank(frame)) {
blankClearCount++
libassLastSlot = target
libassLastFrame = frame
lastPostedSlot = target
return Outcome.Post(target, frame, newContent = true, specHit = false)
}
val lastSlot = libassLastSlot
val lastFrame = libassLastFrame ?: return Outcome.Skip
if (lastSlot < 0) return Outcome.Skip
@@ -169,13 +182,21 @@ internal class SpecRenderEngine(
}
val gen = stateGeneration()
val specPts = servicedPtsUs + medianDeltaUs()
val frame = renderAt(specPts / 1000, target) ?: run {
val frame = renderAt(toLibassMs(specPts), target) ?: run {
specSkips++
return null
}
specGen = gen
specPtsUs = specPts
if (frame.changed == 0) {
if (isImplicitBlank(frame)) {
blankClearCount++
libassLastSlot = target
libassLastFrame = frame
specIsLibassLast = false
specSlot = target
return SpecWrite(target, frame)
}
// Content at specPts is identical to libass's last render — nothing was
// written; a hit will repost libassLastSlot (and GL will skip the upload).
specIsLibassLast = true
@@ -189,6 +210,9 @@ internal class SpecRenderEngine(
return SpecWrite(target, frame)
}
private fun isImplicitBlank(frame: AssAtlasFrame): Boolean =
!frame.hasOutput && libassLastFrame?.hasOutput == true
/**
* Pre-renders [ptsUs] (an upcoming event's start) purely to warm the
* renderer's glyph/bitmap caches before that content is actually needed —
@@ -204,7 +228,7 @@ internal class SpecRenderEngine(
fun prefetch(ptsUs: Long): SpecWrite? {
specPtsUs = UNSET
val target = renderTargetSlot() ?: return null
val frame = renderAt(ptsUs / 1000, target) ?: return null
val frame = renderAt(toLibassMs(ptsUs), target) ?: return null
prefetchCount++
if (frame.changed == 0) return null
libassLastSlot = target
@@ -253,13 +277,12 @@ internal class SpecRenderEngine(
return copy[deltaCount / 2]
}
private fun epsilonUs(): Long = if (deltaValid()) minOf(medianDeltaUs() / 2, EPSILON_CAP_US) else 0L
private fun toLibassMs(ptsUs: Long): Long = Math.floorDiv(ptsUs, 1_000L)
private companion object {
const val UNSET = Long.MIN_VALUE
const val DELTA_SAMPLES = 8
const val MIN_DELTA_SAMPLES = 4
const val MAX_DELTA_US = 250_000L
const val EPSILON_CAP_US = 8_000L
}
}
@@ -53,7 +53,9 @@ class SpecRenderEngineTest {
fun changed(quads: Int = 5) = AssAtlasFrame(2048, 100, quads, 2, 0)
fun unchanged() = AssAtlasFrame(0, 0, 0, 0, 0)
fun unchanged() = AssAtlasFrame(0, 0, 0, 0, 0, hasOutput = true)
fun blankUnchanged() = AssAtlasFrame(0, 0, 0, 0, 0, hasOutput = false)
}
@Test
@@ -74,15 +76,40 @@ class SpecRenderEngineTest {
}
@Test
fun `hit tolerates jitter within epsilon`() {
fun `hit tolerates jitter only within the same libass millisecond`() {
val h = Harness()
val last = h.prime()
val outcome = h.engine.service(last + DELTA + 3_000, pinned = true)
val outcome = h.engine.service(last + DELTA + 999, pinned = true)
assertTrue((outcome as SpecRenderEngine.Outcome.Post).specHit)
}
@Test
fun `jitter crossing a libass millisecond boundary misses`() {
val h = Harness()
val last = h.prime()
val before = h.calls.size
h.script.add(changed())
val outcome = h.engine.service(last + DELTA + 1_000, pinned = true)
assertTrue(outcome is SpecRenderEngine.Outcome.Post)
assertFalse((outcome as SpecRenderEngine.Outcome.Post).specHit)
assertEquals(before + 1, h.calls.size)
assertEquals(1L, h.engine.specMisses)
}
@Test
fun `negative pts floors to the preceding libass millisecond`() {
val h = Harness()
h.script.add(changed())
h.engine.service(-1, pinned = true)
assertEquals(-1L, h.calls.last().timeMs)
}
@Test
fun `seek misses and renders on demand into the spec slot`() {
val h = Harness()
@@ -201,6 +228,23 @@ class SpecRenderEngineTest {
assertEquals(SpecRenderEngine.Outcome.Skip, h.engine.service(0, pinned = true))
}
@Test
fun `changed 0 without output clears previous content`() {
val h = Harness()
h.script.add(changed())
val first = h.engine.service(0, pinned = true) as SpecRenderEngine.Outcome.Post
h.script.add(blankUnchanged())
val outcome = h.engine.service(DELTA, pinned = true) as SpecRenderEngine.Outcome.Post
assertTrue(outcome.newContent)
assertFalse(outcome.specHit)
assertFalse(outcome.frame.hasOutput)
assertEquals(0, outcome.frame.quadCount)
assertTrue("blank frame should use a fresh metadata slot", outcome.slot != first.slot)
assertEquals(1L, h.engine.blankClearCount)
}
@Test
fun `renderer gone skips`() {
val h = Harness()
@@ -246,6 +290,34 @@ class SpecRenderEngineTest {
assertNotNull(first) // first slot existed; hit reposts whichever slot was last rendered
}
@Test
fun `speculative changed 0 without output clears on hit`() {
val h = Harness()
h.script.add(changed())
h.engine.service(0, pinned = true)
var pts = 0L
repeat(4) {
pts += DELTA
h.script.add(changed())
h.engine.service(pts, pinned = true)
}
h.script.add(blankUnchanged())
val write = h.engine.speculateAfter(pts, pinned = true, hasPending = false)
assertNotNull(write)
assertFalse(write!!.frame.hasOutput)
val before = h.calls.size
val outcome = h.engine.service(pts + DELTA, pinned = true) as SpecRenderEngine.Outcome.Post
assertTrue(outcome.specHit)
assertFalse(outcome.newContent)
assertFalse(outcome.frame.hasOutput)
assertEquals(0, outcome.frame.quadCount)
assertEquals(before, h.calls.size)
assertEquals(1L, h.engine.blankClearCount)
}
@Test
fun `speculative write reports slot for seq bump`() {
val h = Harness()
-2
View File
@@ -24,6 +24,4 @@ plugins {
}
include(":app")
// libass subtitle module (Kotlin/JNI bindings + Media3 glue); the native libass
// core stays the upstream Maven artifact io.github.peerless2012:ass.
include(":libass")