WebRTC.kt
src/jvmMain/kotlin/afield/media/webrtc/WebRTC.kt · 502 lines
package afield.media.webrtc
import afield.app.protocol.Handled
import afield.app.protocol.Message
import afield.app.protocol.handlers
import afield.app.protocol.payload
import afield.app.diagnostics.logging.lprintln
import afield.app.server.globalServerPort
import afield.document.context.fromContext
import afield.graphics.texture.HasImageView
import afield.graphics.texture.ImageView
import afield.graphics.texture.PreparesSampledImages
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.targets.HasRenderToTexture
import afield.graphics.vulkan.device.CommandBuffer
import afield.media.browser.FieldUiBrowserTexture
import afield.media.stream.ElectronRTTView
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
WebRTC video for Field 4 graphics: publish a render target to a classroom, and sample anyone's published stream as a texture.
webrtcSink publishes what you render into its rtt; webrtcSource is a texture showing one
publisher; webrtcPublishers is the live list of who is publishing. They join aaaframe's
ShaderTrinket network (set WebRTCNetwork.default, or AFIELD_WEBRTC_SERVER), where browser
ShaderTrinkets see a Field sink as one more publisher, or Field's own relay for a LAN of Field
instances. Chromium does the WebRTC; Field moves the frames on the GPU and relays signalling.
How and why: dev_docs/WEBRTC_SOURCES_SINKS.md.
The same uid recurs across documents opened from one transcript, so key by the Box object itself, which a re-run keeps and another document does not share.
private fun boxKey(name: String): String? = RunScope.executingBox()?.let { "${System.identityHashCode(it)}:${it.id}:$name" }
private fun String.urlEncoded(): String = URLEncoder.encode(this, StandardCharsets.UTF_8)
private fun jsString(value: String): String = JsonPrimitive(value).toString()
class WebRTCSink
A render target published as a WebRTC stream, named login/name on its network.
Render into rtt; display("main", sink.html()) shows the frames in the output area and
publishes them. Every viewer that watches the name gets its own peer connection, fed by
canvas.captureStream on that output-area canvas, so the frames go GPU → canvas → encoder
without readback. Needs the Field Electron app (macOS, or Linux under native Wayland); a
plain browser shows "needs the Field Electron app" and publishes nothing.
val sink = webrtcSink("orbit")
val module = moduleHere { +sink }
module += "quad" to quad
sink += "content" to module
display("main", sink.html())
The sink is the box's ElectronRTTView, so re-running the box replaces it and deleting the box stops publishing.
class WebRTCSink(
/** The output-area view the stream is captured from. */
val view: ElectronRTTView,
/** The publish suffix: the stream is `login/name`, or just `login` when empty. */
val name: String,
val network: WebRTCNetwork = WebRTCNetwork.default,
/** The frame rate asked of `canvas.captureStream`; Chromium encodes at most this many a second. */
val framesPerSecond: Int = 30,
) : HasRenderToTexture {
override val rtt get() = view.rtt
Render into this; +sink inside moduleHere { } and sink += "name" to module use it.
var publishedAs: String?
The name viewers watch, once the network has confirmed it (login/name); null until then.
@Volatile
var publishedAs: String? = null
internal set
var status: String
The page's last status line: "publishing @…", "publishing @… to 2 viewer(s)", or an error.
@Volatile
var status: String = "not shown yet"
internal set
var viewers: Int
How many peers are receiving the stream now.
@Volatile
var viewers: Int = 0
internal set
init
init {
installHandlers()
// The sign-in handler lives with the publisher list; a sink alone can need it too.
WebRTCPublishers.installHandlers()
sinks[view.viewId] = this
}
fun html(): String
The output-area widget: ElectronRTTView.html, plus the script that publishes its canvas and a status line naming the stream.
fun html(): String = view.html() + """
<div data-field-webrtc-sink="${view.viewId}" style="font:11px monospace;opacity:.75;padding:2px 0">WebRTC: starting</div>
<script type="module">
import { send } from "/afield.mjs";
import { publish } from "/webrtc-signal.js";
import { signInControl } from "/webrtc-sign-in.js";
const viewId = ${jsString(view.viewId)};
const label = document.querySelector(`[data-field-webrtc-sink="${'$'}{viewId}"]`);
const signIn = document.createElement("span");
label.after(signIn);
const canvas = document.querySelector(`canvas[data-field-rtt-view="${'$'}{viewId}"]`);
const report = (status, name, viewers) => {
label.textContent = "WebRTC: " + status;
if (status.includes("(4401)") && ${jsString(network.baseUrl.orEmpty())}) signInControl(signIn, ${jsString(network.baseUrl.orEmpty())});
else if (status.startsWith("publishing")) signIn.replaceChildren();
send("webrtc-sink-status", { viewId, status, name: name || null, viewers: viewers || 0 });
};
if (!(window.fieldElectron && window.fieldElectron.rttView)) {
report("needs the Field Electron app; not publishing");
} else if (!canvas || !canvas.captureStream) {
report("this canvas cannot be captured");
} else {
const stream = canvas.captureStream($framesPerSecond);
let publisher = null;
publish({ network: ${jsString(network.id)}, stream, suffix: ${jsString(name)},
onStatus: (s, n) => report(s, n, publisher ? publisher.viewers : 0) })
.then((p) => { publisher = p; (window.fieldWebrtcSinks ||= {})[viewId] = p; })
.catch((e) => report("error: " + e.message));
const observer = new MutationObserver(() => {
if (!canvas.isConnected) {
observer.disconnect();
if (publisher) publisher.stop();
stream.getTracks().forEach((t) => t.stop());
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
</script>
""".trimIndent()
companion object
companion object {
private val sinks = ConcurrentHashMap<String, WebRTCSink>()
private var installed = false
fun timecode(): Float = (System.currentTimeMillis() % (1L shl 24)).toFloat()
The sender's wall clock in ms modulo 2^24, exact in a float: what a time-code band
encodes so a receiver on the same machine can measure latency (webrtc-source.html,
measure=1). Draw bit 23 - i of it as bar i + 1 of 26 across the middle of the frame,
with bar 0 white and bar 25 black.
@Synchronized private fun installHandlers() {
if (installed) return
installed = true
handlers["webrtc-sink-status"] = mutableSetOf({ message, _ ->
val p = message.payload!!.jsonObject
sinks[p["viewId"]?.jsonPrimitive?.contentOrNull]?.let { sink ->
sink.status = p["status"]?.jsonPrimitive?.contentOrNull.orEmpty()
p["name"]?.jsonPrimitive?.contentOrNull?.let { sink.publishedAs = it }
sink.viewers = p["viewers"]?.jsonPrimitive?.contentOrNull?.toIntOrNull() ?: 0
}
Handled.yes
})
}
}
}
fun webrtcSink
Publishes a new width×height render target as login/name; render into the sink's rtt and
display its html(). See WebRTCSink.
fun webrtcSink(
name: String,
width: Int = 640,
height: Int = 360,
network: WebRTCNetwork = WebRTCNetwork.default,
renderer: Renderer = fromContext(),
): WebRTCSink = WebRTCSink(ElectronRTTView(width, height, renderer = renderer), name, network)
fun webrtcSink(view: ElectronRTTView, name: String, network: WebRTCNetwork = WebRTCNetwork.default): WebRTCSink
Publishes an existing ElectronRTTView as login/name.
fun webrtcSink(view: ElectronRTTView, name: String, network: WebRTCNetwork = WebRTCNetwork.default): WebRTCSink =
WebRTCSink(view, name, network)
class WebRTCSource internal constructor
One publisher's WebRTC stream as a Field texture: textures("remote" to source).
A hidden Electron page watches target and plays the video full-frame (letterboxed to keep its aspect); its paints arrive as a texture, GPU to GPU on macOS and through the CPU-bitmap fallback on Linux. Until the publisher is online the texture shows a status line ("waiting for @alice to publish"), and it reconnects by itself when the publisher restarts. watch switches to another publisher without reloading.
Re-running the box keeps the same page and re-targets it, so live-editing does not restart the
stream; deleting the box closes it. Needs Electron installed (./gradlew electronInstall); the
Field window itself need not be the Electron app.
class WebRTCSource internal constructor(
private val renderer: Renderer,
target: String,
val width: Int,
val height: Int,
val network: WebRTCNetwork,
private val fit: String,
measure: Boolean,
) : HasImageView, PreparesSampledImages, AutoCloseable {
var target: String
The publisher shown, as normalised by the network (alice/left).
@Volatile
var target: String = normalizePublishName(target)
private set
var status: String
The page's last status: "requesting @…", "waiting for @… to publish", "receiving @…", or an error.
@Volatile
var status: String = "starting"
private set
val browser
The hidden page's browser, for anything else you want to ask it (it is a full PuppetBrowser).
val browser = FieldUiBrowserTexture(
renderer = renderer,
url = "https://localhost:$globalServerPort/webrtc-source.html?network=${network.id.urlEncoded()}" +
"&target=${this.target.urlEncoded()}&fit=${fit.urlEncoded()}${if (measure) "&measure=1" else ""}",
width = width,
height = height,
// Linux: Chromium's shared-texture export never fills the buffer (electron#53271).
cpuBitmapFallback = FieldUiBrowserTexture.cpuBitmapFallbackRequired,
)
val latency = LatencyStats()
Latency samples decoded from a sink's time-code band (only with measure = true).
private val uploadKey = "webrtc-source-${counter.incrementAndGet()}"
init
init {
browser.addEventListener { event ->
if (event.name != "page-event") return@addEventListener
val name = event.payload["name"]?.jsonPrimitive?.contentOrNull
val value = event.payload["value"]
when (name) {
"webrtc-status" -> (value as? JsonObject)?.let { report ->
val pageTarget = report["target"]?.jsonPrimitive?.contentOrNull
// A watch sent before the page had loaded is lost; the page's first report says so.
// (`this.`: in init, a bare `target` is the constructor's original argument.)
if (pageTarget != null && normalizePublishName(pageTarget) != this.target) watch(this.target)
else report["status"]?.jsonPrimitive?.contentOrNull?.let { status = it }
}
"webrtc-latency" -> (value as? JsonArray)?.forEach { sample ->
val o = sample.jsonObject
latency.add(
o["latency"]?.jsonPrimitive?.doubleOrNull ?: return@forEach,
o["received"]?.jsonPrimitive?.doubleOrNull,
)
}
}
}
renderer.renderList.preTargets[uploadKey] = Target { command, _ -> browser.prepareSampledImagesFor(command) }
}
override val imageView: ImageView get() = browser.imageView
override fun prepareSampledImagesFor(command: CommandBuffer)
override fun prepareSampledImagesFor(command: CommandBuffer) = browser.prepareSampledImagesFor(command)
val pageReady: Boolean get() = browser.pageReady
True once the page has painted a frame after loading (the stream itself may still be connecting).
fun watch(name: String)
Show name's stream instead (alice, @alice/left).
fun watch(name: String) {
target = normalizePublishName(name)
status = "requesting @$target"
browser.evaluate("window.fieldWebrtc && window.fieldWebrtc.watch(${jsString(target)})")
}
override fun close()
override fun close() {
renderer.renderList.preTargets.remove(uploadKey)
sources.values.remove(this)
browser.close()
}
internal fun matches(renderer: Renderer, width: Int, height: Int, network: WebRTCNetwork, fit: String)
internal fun matches(renderer: Renderer, width: Int, height: Int, network: WebRTCNetwork, fit: String) =
renderer === this.renderer && width == this.width && height == this.height && network === this.network && fit == this.fit
class LatencyStats
Percentiles of decoded time codes: sender's render to this page presenting the frame.
class LatencyStats {
private val samples = ArrayDeque<Pair<Double, Double?>>()
var count = 0L; private set
@Synchronized internal fun add(latency: Double, received: Double?)
@Synchronized internal fun add(latency: Double, received: Double?) {
count++
samples.addLast(latency to received)
while (samples.size > 600) samples.removeFirst()
}
@Synchronized fun clear() { samples.clear(); count = 0 }
override fun toString(): String
p50/p90/p99/max over the last 600 frames, of render → presented and render → packet received.
@Synchronized
override fun toString(): String {
if (samples.isEmpty()) return "no time codes decoded yet"
fun line(label: String, values: List<Double>): String {
if (values.isEmpty()) return "$label n/a"
val v = values.sorted()
fun p(q: Double) = "%.1f".format(v[((v.size - 1) * q).toInt()])
return "$label p50 ${p(0.5)} p90 ${p(0.9)} p99 ${p(0.99)} max ${p(1.0)} ms"
}
return listOf(
"frames $count, window ${samples.size}",
line("render → presented", samples.map { it.first }),
line("render → received ", samples.mapNotNull { it.second }),
).joinToString("\n")
}
}
internal companion object
val counter = AtomicInteger()
val sources = ConcurrentHashMap<String, WebRTCSource>()
fun webrtcSource
target's published stream as a width×height texture: textures("remote" to webrtcSource("alice")).
fit is the video's CSS object-fit (contain, cover or fill). key tells several sources
in one box apart, so a re-run reuses each one's page; it defaults to the target. With measure,
a sink's time-code band is decoded into WebRTCSource.latency. See WebRTCSource.
fun webrtcSource(
target: String,
width: Int = 640,
height: Int = 360,
network: WebRTCNetwork = WebRTCNetwork.default,
fit: String = "contain",
key: String = target,
measure: Boolean = false,
renderer: Renderer = fromContext(),
): WebRTCSource {
val boxKey = boxKey(key)
if (boxKey != null) {
val existing = WebRTCSource.sources[boxKey]
if (existing != null && existing.matches(renderer, width, height, network, fit) && !measure) {
if (existing.target != normalizePublishName(target)) existing.watch(target)
return existing
}
existing?.close()
}
val source = WebRTCSource(renderer, target, width, height, network, fit, measure)
if (boxKey != null) {
WebRTCSource.sources[boxKey] = source
// Released when the box is deleted or its document closes; a re-run finds it above.
RunScope.current()?.own(source, ReleasePolicy.KEYED)
}
return source
}
class WebRTCPublishers internal constructor(val network: WebRTCNetwork)
Who is publishing on a network, kept live: names now, onChange as it changes, and html as a panel with a button per publisher that calls onPick.
val source = webrtcSource("alice")
val list = webrtcPublishers()
list.onPick { source.watch(it) }
display("publishers", list.html())
class WebRTCPublishers internal constructor(val network: WebRTCNetwork) {
private val lobby = PublisherLobby.of(network)
private val picks = CopyOnWriteArrayList<(String) -> Unit>()
private val id = "webrtc-publishers-${counter.incrementAndGet()}"
private val removers = CopyOnWriteArrayList<() -> Unit>()
val names: List<String> get() = lobby.names
The publisher names now, sorted.
val error: String? get() = lobby.error
Why the list is empty, if it is because the network refused or dropped the connection.
fun onChange(listener: (List<String>) -> Unit): () -> Unit = lobby.onChange(listener).also { removers += it }
Calls listener with the names now and on every change; returns the remover.
fun onChange(listener: (List<String>) -> Unit): () -> Unit = lobby.onChange(listener).also { removers += it }
fun onPick(listener: (String) -> Unit) { picks += listener }
Calls listener with the name a button in html was clicked for.
fun html(highlight: () -> String?
A panel listing the publishers, live; highlight marks the one you are showing.
body — 42 lines, from line 394
fun html(highlight: () -> String? = { null }): String {
lists[id] = this
highlights[id] = highlight
return """
<div data-field-webrtc-publishers="$id" style="font:12px ui-monospace,monospace;display:flex;flex-wrap:wrap;gap:4px;align-items:center">
<span style="opacity:.6">publishers:</span><span class="list" style="display:contents"></span>
</div>
<script type="module">
import { send, sendWithReply } from "/afield.mjs";
import { signInControl } from "/webrtc-sign-in.js";
const id = ${jsString(id)};
const root = document.querySelector(`[data-field-webrtc-publishers="${'$'}{id}"]`);
const list = root.querySelector(".list");
const draw = (p) => {
list.replaceChildren();
if (p.signIn) { const e = document.createElement("span"); list.append(e); signInControl(e, p.signIn); }
else if (p.error) { const e = document.createElement("span"); e.textContent = p.error; e.style.color = "#e88"; list.append(e); }
else if (!p.names.length) { const e = document.createElement("span"); e.textContent = "nobody yet"; e.style.opacity = ".5"; list.append(e); }
for (const name of p.names) {
const b = document.createElement("button");
b.textContent = "@" + name;
b.style.cssText = "font:inherit;padding:1px 6px;border-radius:3px;border:1px solid #586;cursor:pointer;" +
(name === p.highlight ? "background:#2a6;color:#fff" : "background:transparent;color:inherit");
b.onclick = () => send("webrtc-publishers-pick", { id, name });
list.append(b);
}
};
sendWithReply("webrtc-publishers-subscribe", { id }, id, (m, p) => draw(p));
</script>
""".trimIndent()
}
private val replies = CopyOnWriteArrayList<(Message) -> Unit>()
private fun send(reply: (Message) -> Unit, names: List<String>) {
reply(Message(id).payload(buildJsonObject {
put("names", JsonArray(names.map(::JsonPrimitive)))
lobby.error?.let { put("error", it) }
if (lobby.error == LOGIN_REFUSED) network.baseUrl?.let { put("signIn", it) }
highlights[id]?.invoke()?.let { put("highlight", it) }
}))
}
internal fun push(reply: (Message) -> Unit) {
internal fun push(reply: (Message) -> Unit) {
replies += reply
removers += lobby.onChange { names -> send(reply, names) }
}
internal fun picked(name: String)
internal fun picked(name: String) {
picks.forEach { runCatching { it(name) } }
// The pick usually changes what is highlighted.
replies.forEach { runCatching { send(it, lobby.names) } }
}
fun close()
Stops listening; the panel stops updating.
fun close() {
removers.forEach { it() }
removers.clear()
replies.clear()
lists.remove(id)
highlights.remove(id)
}
internal companion object
val counter = AtomicInteger()
val lists = ConcurrentHashMap<String, WebRTCPublishers>()
val highlights = ConcurrentHashMap<String, () -> String?>()
private var installed = false
@Synchronized fun installHandlers()
@Synchronized fun installHandlers() {
if (installed) return
installed = true
handlers["webrtc-publishers-subscribe"] = mutableSetOf({ message, reply ->
lists[message.payload!!.jsonObject["id"]?.jsonPrimitive?.contentOrNull]?.push(reply)
Handled.yes
})
handlers["webrtc-aaaframe-signed-in"] = mutableSetOf({ message, _ ->
val p = message.payload!!.jsonObject
val base = p["network"]?.jsonPrimitive?.contentOrNull
val session = p["session"]?.jsonPrimitive?.contentOrNull
val login = p["login"]?.jsonPrimitive?.contentOrNull
// Only a network box code has registered: a client on /ws cannot make Field remember
// a server of its choosing.
val network = WebRTCNetwork.lookup(base)?.takeIf { base != null && !it.isLocal }
if (network != null && !session.isNullOrBlank() && login != null) {
network.signedIn(session, login)
PublisherLobby.existing(network)?.signedIn()
lprintln("info,webrtc", "signed in to $base as @$login")
}
Handled.yes
})
handlers["webrtc-publishers-pick"] = mutableSetOf({ message, _ ->
val p = message.payload!!.jsonObject
val name = p["name"]?.jsonPrimitive?.contentOrNull
if (name != null) lists[p["id"]?.jsonPrimitive?.contentOrNull]?.picked(name)
Handled.yes
})
}
}
}
fun webrtcPublishers(network: WebRTCNetwork = WebRTCNetwork.default): WebRTCPublishers
The live publisher list on network; see WebRTCPublishers. Closed with the box's run.
fun webrtcPublishers(network: WebRTCNetwork = WebRTCNetwork.default): WebRTCPublishers {
WebRTCPublishers.installHandlers()
val publishers = WebRTCPublishers(network)
RunScope.current()?.own(ReleasePolicy.ACCUMULATING) { publishers.close() }
return publishers
}