ExternalBrowserSurface.kt
src/jvmMain/kotlin/afield/media/browser/ExternalBrowserSurface.kt · 422 lines
package afield.media.browser
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.MetalIOSurface
import afield.graphics.texture.PreparesSampledImages
import org.lwjgl.system.APIUtil.apiCreateLibrary
import org.lwjgl.system.JNI
import org.lwjgl.system.SharedLibrary
import org.lwjgl.system.windows.Kernel32
import java.util.concurrent.atomic.AtomicBoolean
The resident texture that browser paints arrive in: a fixed-size image Field samples like any other, refreshed from frames a producer offers between draws.
ExternalBrowserSurfaceSpec fixes size, pixel format and slot count; a producer calls
ExternalBrowserSurface.offerFrame with an ExternalBrowserFrame, and the platform import or
copy is recorded in prepareSampledImagesFor before the image is bound. ExternalBrowserSurfaces.create
picks the macOS, Windows or Linux backend. Frame hand-off is covered by ExternalBrowserFrameMailboxTest.
data class ExternalBrowserSurfaceSpec
Fixed properties of the resident texture exposed to Field.
data class ExternalBrowserSurfaceSpec(
val width: Int,
val height: Int,
val pixelFormat: BrowserSurfacePixelFormat = BrowserSurfacePixelFormat.BGRA8_SRGB,
val slotCount: Int = 3,
val deviceScaleFactor: Double = 1.0
) {
init
init {
require(width > 0) { "width must be positive" }
require(height > 0) { "height must be positive" }
require(slotCount > 0) { "slotCount must be positive" }
require(deviceScaleFactor > 0.0 && deviceScaleFactor.isFinite()) {
"deviceScaleFactor must be finite and positive"
}
}
}
enum class BrowserSurfacePixelFormat
Pixel layouts and transfer functions accepted from accelerated browser producers.
enum class BrowserSurfacePixelFormat {
BGRA8_SRGB,
RGBA8_SRGB,
BGRA8_UNORM,
RGBA8_UNORM,
RGBA16_FLOAT
}
data class BrowserSurfaceColorSpace
Electron's four color-space fields, preserved without baking its evolving string vocabulary in.
data class BrowserSurfaceColorSpace(
val primaries: String,
val transfer: String,
val matrix: String,
val range: String
)
data class BrowserSurfaceRect(val x: Int, val y: Int, val width: Int, val height: Int)
init
init {
require(width >= 0) { "width must not be negative" }
require(height >= 0) { "height must not be negative" }
}
}
sealed interface ExternalBrowserImageHandle : AutoCloseable
A receiver-side platform handle. IPC is responsible for turning Electron's process-local handle into one of these values. Each value owns the receiver-local handle(s) until a platform importer either consumes them or closes them. Closing a rejected or superseded frame is therefore enough to keep transport resources bounded.
sealed interface ExternalBrowserImageHandle : AutoCloseable {
override fun close() = Unit
Releases any live receiver-side transport resource. Implementations must be idempotent.
class MacIOSurfaceMachPort(val value: Int) : ExternalBrowserImageHandle
A Mach port name in Field's process after the producer transferred an IOSurface send right. This is the normal Electron path: Electron's IOSurfaceRef itself is process-local.
class MacIOSurfaceMachPort(val value: Int) : ExternalBrowserImageHandle {
private val closed = AtomicBoolean(false)
init { require(value != 0) { "IOSurface Mach port must be non-zero" } }
override fun close()
override fun close() {
if (closed.compareAndSet(false, true)) MetalIOSurface.deallocateMachPort(value)
}
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean = other is MacIOSurfaceMachPort && value == other.value
override fun hashCode(): Int = value
override fun toString(): String = "MacIOSurfaceMachPort(value=$value)"
data class MacIOSurfaceId(val value: Long) : ExternalBrowserImageHandle
Same-process or explicitly global IOSurfaces only; not the normal Electron transport.
init
init { require(value in 1..0xFFFF_FFFFL) { "IOSurfaceID must be an unsigned non-zero 32-bit value" } }
}
class WindowsD3D11NtHandle(val value: Long) : ExternalBrowserImageHandle
class WindowsD3D11NtHandle(val value: Long) : ExternalBrowserImageHandle {
private val closed = AtomicBoolean(false)
init { require(value != 0L) { "NT handle must be non-zero" } }
override fun close()
override fun close() {
if (closed.compareAndSet(false, true)) ExternalBrowserNativeHandles.closeWin32(value)
}
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean = other is WindowsD3D11NtHandle && value == other.value
override fun hashCode(): Int = value.hashCode()
override fun toString(): String = "WindowsD3D11NtHandle(value=$value)"
class CpuBitmap(val pixels: java.nio.ByteBuffer, val stride: Int) : ExternalBrowserImageHandle
Pixels that have already been read back to the CPU, for the non-zero-copy fallback. Not a native handle at all: nothing is shared, and the bytes are owned by this value until close frees them.
class CpuBitmap(val pixels: java.nio.ByteBuffer, val stride: Int) : ExternalBrowserImageHandle {
private val closed = AtomicBoolean(false)
init
init {
require(pixels.isDirect) { "CPU bitmap pixels must be a direct buffer" }
require(stride > 0) { "CPU bitmap stride must be positive" }
}
override fun close()
override fun close() {
if (closed.compareAndSet(false, true)) org.lwjgl.system.MemoryUtil.memFree(pixels)
}
override fun toString(): String = "CpuBitmap(${pixels.capacity()} bytes, stride=$stride)"
class LinuxDmaBuf
class LinuxDmaBuf(
val planes: List<Plane>,
/** DRM modifier bits, represented as a signed Long without reinterpretation. */
val modifier: Long
) : ExternalBrowserImageHandle {
private val closed = AtomicBoolean(false)
init { require(planes.isNotEmpty()) { "dma-buf must contain at least one plane" } }
override fun close()
override fun close() {
if (closed.compareAndSet(false, true)) {
var failure: Throwable? = null
planes.map { it.fd }.distinct().forEach { fd ->
runCatching { ExternalBrowserNativeHandles.closeFd(fd) }
.onFailure { if (failure == null) failure = it }
}
failure?.let { throw it }
}
}
internal fun markConsumedByVulkan()
Successful Vulkan fd import consumes ownership; prevent a later frame close from closing it again.
internal fun markConsumedByVulkan() {
check(closed.compareAndSet(false, true)) { "dma-buf fds were already closed or consumed" }
}
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean =
other is LinuxDmaBuf && planes == other.planes && modifier == other.modifier
override fun hashCode(): Int = 31 * planes.hashCode() + modifier.hashCode()
override fun toString(): String = "LinuxDmaBuf(planes=$planes, modifier=$modifier)"
data class Plane(val fd: Int, val stride: Int, val offset: Int, val size: Long)
init
init {
require(fd >= 0) { "dma-buf fd must be non-negative" }
require(stride > 0) { "dma-buf stride must be positive" }
require(offset >= 0) { "dma-buf offset must be non-negative" }
require(size > 0) { "dma-buf size must be positive" }
}
}
}
}
data class ExternalBrowserFrame
One accelerated browser paint. Stream ids increase whenever the producer is replaced. handle
is sticky within one streamId/slot: it may be omitted after the first announcement of that
slot, but a new stream must announce its handles again. releaseToken is echoed when the GPU has
finished copying the frame, allowing Electron's main process to call
OffscreenSharedTexture.release() safely.
data class ExternalBrowserFrame(
val streamId: Long,
val frameId: Long,
val releaseToken: Long = frameId,
val slot: Int,
val width: Int,
val height: Int,
val pixelFormat: BrowserSurfacePixelFormat,
val handle: ExternalBrowserImageHandle? = null,
val colorSpace: BrowserSurfaceColorSpace? = null,
val visibleRect: BrowserSurfaceRect? = null,
val contentRect: BrowserSurfaceRect? = null,
val timestampMicros: Long? = null
)
enum class BrowserFrameReleaseReason
enum class BrowserFrameReleaseReason {
The copy into Field's resident sampled texture completed on the GPU.
PRESENTED,
A newer paint arrived before this one was copied.
SUPERSEDED,
The producer sent an old or duplicate frame.
STALE,
The frame did not match the negotiated surface.
INVALID,
The platform handle could not be imported.
IMPORT_FAILED,
The receiving surface was closed or its producer stream was replaced.
CLOSED
}
data class BrowserFrameRelease
data class BrowserFrameRelease(
val streamId: Long,
val frameId: Long,
val releaseToken: Long,
val slot: Int,
val reason: BrowserFrameReleaseReason
)
fun interface BrowserFrameReleaseListener
fun released(release: BrowserFrameRelease)
enum class BrowserFrameOfferResult
enum class BrowserFrameOfferResult {
ACCEPTED,
ACCEPTED_REPLACING_PENDING,
REJECTED_STALE,
REJECTED_INVALID,
REJECTED_CLOSED
}
interface ExternalBrowserSurface :
A browser surface is a stable Field texture: platform textures are copied on-GPU before their producer lease is released, so Chromium may recycle them without changing or corrupting what Field samples between browser paints.
interface ExternalBrowserSurface :
HasImageView,
HasImageViewArray,
PreparesSampledImages,
AutoCloseable {
val spec: ExternalBrowserSurfaceSpec
val presentedFrame: ExternalBrowserFrame?
fun offerFrame(frame: ExternalBrowserFrame): BrowserFrameOfferResult
Thread-safe; intended to be called by the IPC reader.
override fun prepareSampledImagesFor(command: CommandBuffer)
Records any pending platform import/copy before Field binds imageView.
object ExternalBrowserSurfaces
The platform-neutral construction point; Windows and Linux backends can fill in behind it.
fun create
fun create(
renderer: afield.graphics.vulkan.Renderer,
spec: ExternalBrowserSurfaceSpec,
releaseListener: BrowserFrameReleaseListener = BrowserFrameReleaseListener { }
): ExternalBrowserSurface = when (afield.graphics.vulkan.util.OS) {
afield.graphics.vulkan.util.OSType.MACOS -> MacExternalBrowserSurface(renderer, spec, releaseListener)
afield.graphics.vulkan.util.OSType.WINDOWS -> WindowsExternalBrowserSurface(renderer, spec, releaseListener)
afield.graphics.vulkan.util.OSType.LINUX -> LinuxExternalBrowserSurface(renderer, spec, releaseListener)
afield.graphics.vulkan.util.OSType.OTHER -> error("External browser surfaces are unsupported on this operating system")
}
}
Tiny OS-close bindings kept lazy so this source remains loadable on every desktop OS.
private object ExternalBrowserNativeHandles {
private val closeHandleAddress: Long by lazy {
Kernel32.getLibrary().getFunctionAddress("CloseHandle")
.also { check(it != 0L) { "CloseHandle is unavailable" } }
}
private val libc: SharedLibrary by lazy { apiCreateLibrary("libc.so.6") }
private val closeFdAddress: Long by lazy {
libc.getFunctionAddress("close").also { check(it != 0L) { "libc close is unavailable" } }
}
fun closeWin32(handle: Long) {
check(JNI.callPI(handle, closeHandleAddress) != 0) { "CloseHandle failed for 0x${handle.toString(16)}" }
}
fun closeFd(fd: Int) {
check(JNI.callI(fd, closeFdAddress) == 0) { "close($fd) failed" }
}
}
internal class ExternalBrowserFrameMailbox
Conflates producer paints without ever losing a slot's sticky handle announcement.
internal class ExternalBrowserFrameMailbox(
private val spec: ExternalBrowserSurfaceSpec,
private val releaseListener: BrowserFrameReleaseListener
) {
private data class PendingRelease(
val frame: ExternalBrowserFrame,
val reason: BrowserFrameReleaseReason,
val closeHandle: Boolean = true
)
private val lock = Any()
private val pending = arrayOfNulls<ExternalBrowserFrame>(spec.slotCount)
private var streamId: Long? = null
private var greatestFrameId = -1L
private var closed = false
fun offer(frame: ExternalBrowserFrame): BrowserFrameOfferResult
fun offer(frame: ExternalBrowserFrame): BrowserFrameOfferResult {
val releases = mutableListOf<PendingRelease>()
val result = synchronized(lock) {
if (closed) {
releases += PendingRelease(frame, BrowserFrameReleaseReason.CLOSED)
BrowserFrameOfferResult.REJECTED_CLOSED
} else if (!isValid(frame)) {
releases += PendingRelease(frame, BrowserFrameReleaseReason.INVALID)
BrowserFrameOfferResult.REJECTED_INVALID
} else {
val currentStream = streamId
when {
currentStream != null && frame.streamId < currentStream -> {
releases += PendingRelease(frame, BrowserFrameReleaseReason.STALE)
BrowserFrameOfferResult.REJECTED_STALE
}
currentStream == null || frame.streamId > currentStream -> {
pending.filterNotNull().forEach {
releases += PendingRelease(it, BrowserFrameReleaseReason.CLOSED)
}
pending.fill(null)
streamId = frame.streamId
greatestFrameId = -1L
accept(frame, releases)
}
frame.frameId <= greatestFrameId -> {
releases += PendingRelease(frame, BrowserFrameReleaseReason.STALE)
BrowserFrameOfferResult.REJECTED_STALE
}
else -> accept(frame, releases)
}
}
}
releases.forEach(::release)
return result
}
fun takeLatest(): ExternalBrowserFrame?
Returns only the newest paint; every older pending producer lease is released immediately.
fun takeLatest(): ExternalBrowserFrame? {
val releases = mutableListOf<ExternalBrowserFrame>()
val newest = synchronized(lock) {
val selected = pending.filterNotNull().maxByOrNull { it.frameId }
pending.forEachIndexed { index, frame ->
if (frame != null && frame !== selected) releases += frame
pending[index] = null
}
selected
}
releases.forEach { release(PendingRelease(it, BrowserFrameReleaseReason.SUPERSEDED)) }
return newest
}
fun close()
body — 64 lines, from line 358
fun close() {
val releases = synchronized(lock) {
if (closed) return
closed = true
pending.filterNotNull().also { pending.fill(null) }
}
releases.forEach { release(PendingRelease(it, BrowserFrameReleaseReason.CLOSED)) }
}
private fun accept(
frame: ExternalBrowserFrame,
releases: MutableList<PendingRelease>
): BrowserFrameOfferResult {
val replaced = pending[frame.slot]
// Preserve an unconsumed slot announcement only while replacing a pending frame in the
// same slot. Once a frame reaches the importer, later handle-less frames use its cache.
val accepted = if (frame.handle == null && replaced?.handle != null) {
frame.copy(handle = replaced.handle)
} else frame
if (replaced != null) {
releases += PendingRelease(
frame = replaced,
reason = BrowserFrameReleaseReason.SUPERSEDED,
closeHandle = replaced.handle !== accepted.handle
)
}
pending[frame.slot] = accepted
greatestFrameId = frame.frameId
return if (replaced == null) BrowserFrameOfferResult.ACCEPTED
else BrowserFrameOfferResult.ACCEPTED_REPLACING_PENDING
}
private fun isValid(frame: ExternalBrowserFrame): Boolean =
frame.streamId >= 0 &&
frame.frameId >= 0 &&
frame.slot in pending.indices &&
frame.width == spec.width &&
frame.height == spec.height &&
frame.pixelFormat == spec.pixelFormat &&
frame.visibleRect.isInside(spec) &&
frame.contentRect.isInside(spec) &&
(frame.timestampMicros == null || frame.timestampMicros >= 0)
private fun BrowserSurfaceRect?.isInside(spec: ExternalBrowserSurfaceSpec): Boolean {
if (this == null) return true
return x >= 0 && y >= 0 &&
x.toLong() + width <= spec.width.toLong() &&
y.toLong() + height <= spec.height.toLong()
}
private fun release(pending: PendingRelease) {
val frame = pending.frame
if (pending.closeHandle) runCatching { frame.handle?.close() }
releaseListener.released(
BrowserFrameRelease(
streamId = frame.streamId,
frameId = frame.frameId,
releaseToken = frame.releaseToken,
slot = frame.slot,
reason = pending.reason
)
)
}
}