Skip to content

FieldEncodeProcess.kt

src/jvmMain/kotlin/afield/media/stream/FieldEncodeProcess.kt · 246 lines

package afield.media.stream

import afield.app.diagnostics.logging.lprintln
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedWriter
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread

The JVM side of the field-encode helper, which encodes Field render targets (VideoToolbox on macOS, the field-encode-linux build on Linux) and streams them to the visionOS app.

FieldEncodeProcess starts the helper and exchanges newline-delimited JSON with it; image data never passes through this pipe. FieldRTTStreamer is the usual way to drive it from a box.

class FieldEncodeProcess

Launches and drives the field-encode Swift co-process (native/field-encode) — the macOS VideoToolbox sender for the visionOS stereo/flat docks (see dev_docs/vision_frontend.md -> "The sender seam" and native/field-encode/WIRE_FORMAT.md).

This is pure JVM glue: it spawns the binary, writes newline-delimited JSON commands to its stdin, and reads JSON events from its stdout. Pixels never cross this pipe — the helper attaches to Field's RTT by global IOSurfaceID (passed once in open) and streams HEVC straight to the device over UDP. Field's only jobs are: hand over the surface IDs, and tick frame once each eye's render fence has signalled.

The child is stateless and disposable: if it dies, call start again and re-open.

class FieldEncodeProcess(
    private val binaryPath: String = defaultBinaryPath(),
    private val workingDirectory: String? = null,
) {
    @Volatile
    private var process: Process? = null

    @Volatile
    private var stdin: BufferedWriter? = null

var running: Boolean

    @Volatile
    var running: Boolean = false
        private set

var lastEvent: JSONObject?

    @Volatile
    var lastEvent: JSONObject? = null
        private set

var onEvent: ((JSONObject) -> Unit)? = null

Invoked for every JSON event line on the helper's stdout (opened/encoded/error/...).

var onExit: ((Int) -> Unit)? = null

Invoked once when the process exits, with its exit code.

fun start(): Boolean

body — 49 lines, from line 56
    @Synchronized
    fun start(): Boolean {
        if (running) return true
        val bin = File(binaryPath)
        if (!bin.canExecute()) {
            lprintln("error,field-encode",
                "binary not executable: $binaryPath — build it: (cd native/field-encode && swift build -c release)")
            return false
        }
        val proc = try {
            ProcessBuilder(binaryPath).apply {
                workingDirectory?.let { directory(File(it)) }
            }.start()
        } catch (t: Throwable) {
            lprintln("error,field-encode", "failed to start: ${t.message ?: t}")
            return false
        }
        process = proc
        stdin = proc.outputStream.bufferedWriter()
        running = true

        thread(name = "field-encode-stdout", isDaemon = true) {
            runCatching {
                proc.inputStream.bufferedReader().useLines { lines ->
                    lines.forEach { line ->
                        val trimmed = line.trim()
                        if (trimmed.isEmpty()) return@forEach
                        runCatching { JSONObject(trimmed) }.getOrNull()?.let { ev ->
                            lastEvent = ev
                            onEvent?.invoke(ev)
                        }
                    }
                }
            }
        }
        thread(name = "field-encode-stderr", isDaemon = true) {
            runCatching {
                proc.errorStream.bufferedReader().useLines { lines ->
                    lines.forEach { if (it.isNotBlank()) lprintln("info,field-encode", it) }
                }
            }
        }
        thread(name = "field-encode-wait", isDaemon = true) {
            val code = runCatching { proc.waitFor() }.getOrDefault(-1)
            running = false
            onExit?.invoke(code)
        }
        return true
    }

fun alloc(width: Int, height: Int, count: Int)

Allocate a ring of count IOSurfaces in the helper and get their global IDs back (via an {"ev":"allocated","ids":[…]} event on onEvent). Field imports these by ID as its RTT backing — they carry a real pixelFormat, unlike MoltenVK's export. Precedes open.

    fun alloc(width: Int, height: Int, count: Int) = send(
        JSONObject().put("cmd", "alloc")
            .put("width", width).put("height", height).put("count", count)
    )

fun open

Set up the encoder + UDP stream to dest ("host:port"). On macOS, pass surfaces (global IOSurfaceIDs) to look them up in the helper (legacy); omit to use the ring created by a prior alloc. On Linux, pass fdSurfaces + fdSocket — the helper has no alloc (it can't allocate a Vulkan image itself), and a raw fd number can't travel over JSON (it's per-process, not a system-wide handle like an IOSurfaceID): the real fds cross over an SCM_RIGHTS handoff on fdSocket instead (see afield.graphics.texture.UnixFdPass — caller sends them right after this command), and only size/rowPitch travel on the wire, in the same order as the fds sent over the socket.

    fun open(
        width: Int, height: Int, fps: Int, bitrateKbps: Int,
        dest: String, codec: String = "hevc", idrIntervalMs: Int = 1000,
        surfaces: List<Long> = emptyList(),
        fdSurfaces: List<afield.graphics.texture.VulkanExternalMemory.FdHandle> = emptyList(),
        fdSocket: String? = null,
        stereo: Boolean = false,
    ) = send(
        JSONObject()
            .put("cmd", "open")
            .apply {
                if (fdSurfaces.isNotEmpty()) {
                    put("surfaces", JSONArray(fdSurfaces.map {
                        JSONObject().put("size", it.size).put("rowPitch", it.rowPitch)
                    }))
                    fdSocket?.let { put("fdSocket", it) }
                } else if (surfaces.isNotEmpty()) {
                    put("surfaces", JSONArray(surfaces))
                }
            }
            .put("width", width).put("height", height).put("fps", fps)
            .put("bitrateKbps", bitrateKbps).put("dest", dest)
            .put("codec", codec).put("idrIntervalMs", idrIntervalMs)
            .put("stereo", stereo)
    )

fun frame

Signal that ring surface surface is rendered and ready to encode now.

poseFrameId and poseDisplayTimeNanos identify the head pose this frame was rendered from, for the wire's version-2 pose tail. Both or neither: half a pose sets the wire's POSE_VALID flag over a zero timestamp, and the receiver would reproject to the wrong instant — worse than carrying no pose at all. Omitting them is normal (the desktop and OpenXR paths have no uplinked pose) and the helper then clears the flag.

A helper binary older than the pose tail decodes into properties it does not have and silently emits version-1 headers — which the receiver still accepts, so the symptom is a stream with no pose rather than a broken one. Rebuild the helper if a pose you set never arrives.

    fun frame(
        surface: Int, seq: Long, ptsMicros: Long, forceIdr: Boolean = false,
        poseFrameId: Long? = null, poseDisplayTimeNanos: Long? = null,
    ) = send(
        JSONObject().put("cmd", "frame").put("surface", surface)
            .put("seq", seq).put("ptsMicros", ptsMicros).put("forceIdr", forceIdr)
            .also { json ->
                if (poseFrameId != null && poseDisplayTimeNanos != null) {
                    json.put("poseFrameId", poseFrameId)
                        .put("poseDisplayTimeNanos", poseDisplayTimeNanos)
                }
            }
    )

fun bitrate(kbps: Int) = send(JSONObject().put("cmd", "bitrate").put("kbps", kbps))

fun selftest

Fire the helper's self-contained smoke test (synthetic frames, no RTT needed).

    fun selftest(
        dest: String, frames: Int = 120, width: Int = 1280, height: Int = 720,
        fps: Int = 60, bitrateKbps: Int = 20000, codec: String = "hevc",
    ) = send(
        JSONObject().put("cmd", "selftest").put("dest", dest).put("frames", frames)
            .put("width", width).put("height", height).put("fps", fps)
            .put("bitrateKbps", bitrateKbps).put("codec", codec)
    )

fun send(cmd: JSONObject)

    fun send(cmd: JSONObject) {
        val writer = stdin ?: run {
            lprintln("warn,field-encode", "not running; dropped ${cmd.optString("cmd")}")
            return
        }
        synchronized(writer) {
            runCatching { writer.write(cmd.toString()); writer.write("\n"); writer.flush() }
                .onFailure { lprintln("warn,field-encode", "write failed: ${it.message}") }
        }
    }

fun close()

Graceful: send close, then close stdin (helper exits its readLine loop), then reap.

    fun close() {
        runCatching { send(JSONObject().put("cmd", "close")) }
        stop()
    }

fun stop()

    @Synchronized
    fun stop() {
        val proc = process ?: return
        runCatching { stdin?.close() }
        runCatching {
            proc.destroy()
            if (!proc.waitFor(300, TimeUnit.MILLISECONDS)) {
                proc.destroyForcibly()
                proc.waitFor(300, TimeUnit.MILLISECONDS)
            }
        }
        process = null
        stdin = null
        running = false
    }

companion object

    companion object {

fun defaultBinaryPath(): String

Picks the per-OS sender binary (same control+wire protocol either way — see WIRE_FORMAT.md). Linux builds one CMake target (native/field-encode-linux/build/); macOS prefers the SwiftPM release build, falling back to debug. Paths are relative to the field4 repo root (cwd).

        fun defaultBinaryPath(): String {
            if (afield.graphics.vulkan.util.OS == afield.graphics.vulkan.util.OSType.LINUX) {
                return File("native/field-encode-linux/build/field-encode-linux").path
            }
            val base = "native/field-encode/.build"
            val release = File("$base/release/field-encode")
            val debug = File("$base/debug/field-encode")
            return when {
                release.canExecute() -> release.path
                debug.canExecute() -> debug.path
                else -> release.path
            }
        }
    }
}