Skip to content

BoxOutputCapture.kt

src/jvmMain/kotlin/afield/ai/agent/BoxOutputCapture.kt · 406 lines

package afield.ai.agent

import afield.app.protocol.BoxScreenshotRequest
import afield.app.protocol.ExpectedOutputs
import afield.tools.docs.SCREENSHOT_PREP_STALLED
import afield.app.server.SessionInformation
import afield.app.server.buildPlaywrightDocumentUrl
import afield.app.server.ensureSharedPlaywrightBrowser
import afield.app.diagnostics.logging.lprintln
import afield.app.server.playwrightDispatcher
import afield.app.server.playwrightSessionReadyScript
import afield.tools.docs.requestBoxScreenshot
import afield.ai.tools.aiToolMapper
import com.fasterxml.jackson.core.type.TypeReference
import com.microsoft.playwright.Browser
import com.microsoft.playwright.BrowserContext
import com.microsoft.playwright.Page
import com.microsoft.playwright.options.Clip
import com.microsoft.playwright.options.WaitUntilState
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam

Pictures of a box's output area for the agent, taken from a headless Playwright page on the box's document, with simple statistics about what the picture contains.

BoxOutputCapture owns the pages and their release; BoxOutputShot is one capture, and PictureStats summarises its pixels: how much differs from the background, and the dominant colours.

data class BoxOutputShot

One captured picture of a box's output area, or the reason there is none.

image is the decoded capture (device pixels). path is where the PNG was kept as evidence, or blank when the host keeps none (tests).

data class BoxOutputShot(
    val success: Boolean,
    val message: String,
    val image: BufferedImage? = null,
    val path: String = "",
    val channels: List<String> = emptyList(),
    val viewerStates: List<Map<String, Any?>> = emptyList(),
) {

companion object

fun failed(message: String, channels: List<String> = emptyList())

        fun failed(message: String, channels: List<String> = emptyList()) = BoxOutputShot(false, message, channels = channels)
    }
}

data class PictureStats

Measurements of a picture that a check, a log line, or a model can use without looking.

Colours are binned to 4 bits a channel. The background is the most common bin; inkFraction is the share of pixels that differ from it by more than INK_TOLERANCE in some channel.

data class PictureStats(
    val width: Int,
    val height: Int,
    val inkFraction: Double,
    /** Up to six `#rrggbb` → share, most common first (bin averages). */
    val dominant: List<Pair<String, Double>>,
) {

companion object

const val INK_TOLERANCE = 24

fun of(image: BufferedImage): PictureStats

        fun of(image: BufferedImage): PictureStats {
            val w = image.width
            val h = image.height
            val bins = HashMap<Int, LongArray>() // key -> [count, r, g, b]
            val step = if (w.toLong() * h > 4_000_000L) 2 else 1
            var total = 0L
            for (y in 0 until h step step) for (x in 0 until w step step) {
                val rgb = image.getRGB(x, y)
                val r = (rgb shr 16) and 0xff; val g = (rgb shr 8) and 0xff; val b = rgb and 0xff
                val key = ((r shr 4) shl 8) or ((g shr 4) shl 4) or (b shr 4)
                val acc = bins.getOrPut(key) { LongArray(4) }
                acc[0]++; acc[1] += r.toLong(); acc[2] += g.toLong(); acc[3] += b.toLong()
                total++
            }
            if (total == 0L) return PictureStats(w, h, 0.0, emptyList())
            val sorted = bins.values.sortedByDescending { it[0] }
            val bg = sorted.first()
            val bgRgb = intArrayOf((bg[1] / bg[0]).toInt(), (bg[2] / bg[0]).toInt(), (bg[3] / bg[0]).toInt())
            var ink = 0L
            for ((_, acc) in bins) {
                val c = intArrayOf((acc[1] / acc[0]).toInt(), (acc[2] / acc[0]).toInt(), (acc[3] / acc[0]).toInt())
                if ((0..2).any { kotlin.math.abs(c[it] - bgRgb[it]) > INK_TOLERANCE }) ink += acc[0]
            }
            val dominant = sorted.take(6).map { acc ->
                "#%02x%02x%02x".format((acc[1] / acc[0]).toInt(), (acc[2] / acc[0]).toInt(), (acc[3] / acc[0]).toInt()) to
                    acc[0].toDouble() / total
            }
            return PictureStats(w, h, ink.toDouble() / total, dominant)
        }

fun coverage(image: BufferedImage, hex: String, tolerance: Int = 48): Double

Share of image's pixels within tolerance (per channel) of hex (#rrggbb).

        fun coverage(image: BufferedImage, hex: String, tolerance: Int = 48): Double {
            val target = parseHex(hex)
            var hit = 0L
            var total = 0L
            val step = if (image.width.toLong() * image.height > 4_000_000L) 2 else 1
            for (y in 0 until image.height step step) for (x in 0 until image.width step step) {
                val rgb = image.getRGB(x, y)
                val r = (rgb shr 16) and 0xff; val g = (rgb shr 8) and 0xff; val b = rgb and 0xff
                if (kotlin.math.abs(r - target[0]) <= tolerance && kotlin.math.abs(g - target[1]) <= tolerance &&
                    kotlin.math.abs(b - target[2]) <= tolerance) hit++
                total++
            }
            return if (total == 0L) 0.0 else hit.toDouble() / total
        }

data class Bar(val x0: Int, val x1: Int, val height: Int, val colour: Int)

One bar found by bars: its columns, its height in pixels, and which of the colours fills most of it.

fun bars(image: BufferedImage, hexes: List<String>, tolerance: Int = 48): List<Bar>

Vertical bars drawn in any of hexes: runs of adjacent columns holding at least 3 pixels of those colours. A bar's height is its tallest column; its colour is the index into hexes with the most pixels in it. Left to right.

        fun bars(image: BufferedImage, hexes: List<String>, tolerance: Int = 48): List<Bar> {
            val targets = hexes.map(::parseHex)
            val out = mutableListOf<Bar>()
            var start = -1; var height = 0; val counts = IntArray(targets.size)
            fun close(x: Int) {
                if (start >= 0) out += Bar(start, x - 1, height, counts.indices.maxByOrNull { counts[it] } ?: 0)
                start = -1; height = 0; counts.fill(0)
            }
            for (x in 0 until image.width) {
                var column = 0
                val here = IntArray(targets.size)
                for (y in 0 until image.height) {
                    val rgb = image.getRGB(x, y)
                    val r = (rgb shr 16) and 0xff; val g = (rgb shr 8) and 0xff; val b = rgb and 0xff
                    val hit = targets.indexOfFirst { t ->
                        kotlin.math.abs(r - t[0]) <= tolerance && kotlin.math.abs(g - t[1]) <= tolerance && kotlin.math.abs(b - t[2]) <= tolerance
                    }
                    if (hit >= 0) { column++; here[hit]++ }
                }
                if (column >= 3) {
                    if (start < 0) start = x
                    height = maxOf(height, column)
                    for (i in here.indices) counts[i] += here[i]
                } else close(x)
            }
            close(image.width)
            return out
        }

fun parseHex(hex: String): IntArray

        fun parseHex(hex: String): IntArray {
            val h = hex.trim().removePrefix("#")
            require(h.length == 6 && h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { "colour must be #rrggbb, not '$hex'" }
            return intArrayOf(h.substring(0, 2).toInt(16), h.substring(2, 4).toInt(16), h.substring(4, 6).toInt(16))
        }
    }

fun describeDominant(): String

#ffffff 62%, #1f77b4 12%, …

    fun describeDominant(): String = dominant.joinToString(", ") { (hex, share) -> "$hex ${"%.1f".format(share * 100)}%" }
}

internal fun jpegDataUrl(image: BufferedImage, maxSide: Int = 1200, quality: Float = 0.85f): String

A JPEG data URL of image, its long side scaled down to at most maxSide pixels.

internal fun jpegDataUrl(image: BufferedImage, maxSide: Int = 1200, quality: Float = 0.85f): String {
    val scale = minOf(1.0, maxSide.toDouble() / maxOf(image.width, image.height))
    val w = maxOf(1, (image.width * scale).toInt())
    val h = maxOf(1, (image.height * scale).toInt())
    val rgb = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
    val g = rgb.createGraphics()
    g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
    g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
    g.drawImage(image, 0, 0, w, h, null)
    g.dispose()
    val out = ByteArrayOutputStream()
    val writer = ImageIO.getImageWritersByFormatName("jpeg").next()
    try {
        ImageIO.createImageOutputStream(out).use { stream ->
            writer.output = stream
            val params = writer.defaultWriteParam.apply {
                compressionMode = ImageWriteParam.MODE_EXPLICIT
                compressionQuality = quality
            }
            writer.write(null, IIOImage(rgb, null, null), params)
        }
    } finally {
        writer.dispose()
    }
    return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray())
}

object BoxOutputCapture

The server-owned browser page AI4 looks through: a picture of one box's output area, taken headless, whether or not anyone has Field open.

Why a page of its own

The backdoor and the docs runner share one global page (afield.app.server.globalPlaywriteContext) and navigate it wherever their current session is. AI4 capturing through it would drag it to another document in the middle of someone else's capture sequence. So this opens its own context in the same Chromium (ensureSharedPlaywrightBrowser) and navigates only that.

Lifecycle

One page per document, keyed by SessionInformation.filename (a second capture of the same document reuses it; a keyed map self-replaces). At most MAX_PAGES; the least recently used is closed to make room. A page is released: - by release, which afield.app.server.closeHeadlessDocument calls before it decides whether the document still has clients — otherwise this page's own websocket would keep every headless task document alive for ever; - after IDLE_RELEASE_MS without a capture, unless it is the only client of an ephemeral document: detaching the last client of a scratch document retires it (cleanupDetachedSession), and a headless task can easily spend two minutes between looks. Those are released by their owner; - by capture's retry, when a capture on it fails outright, including a prep that stalled or that the Playwright driver timed out (afield.tools.docs.evaluateWithin). All of the capture pages and the backdoor share one Playwright thread, so a page that cannot answer must not be asked again.

A late panel is not a problem: a page that attaches after a box ran asks for replay-output-areas and gets every retained panel (OutputAreaReplay), so "run first, look later" works.

const val MAX_PAGES = 4

const val IDLE_RELEASE_MS = 120_000L

    private const val NAVIGATION_TIMEOUT_MS = 20_000.0
    private const val READY_TIMEOUT_MS = 60_000.0
    private const val VIEWPORT_W = 1600
    private const val VIEWPORT_H = 1400
    private const val MAX_KEPT_CAPTURES = 300

    private class Held(val info: SessionInformation, val context: BrowserContext, val page: Page, val browser: Browser) {
        @Volatile var lastUsed = System.currentTimeMillis()
    }

Touched only on the Playwright thread.

    private val held = LinkedHashMap<String, Held>()

    private val sweeper = Executors.newSingleThreadScheduledExecutor { r ->
        Thread(r, "afield-box-output-capture-sweeper").apply { isDaemon = true }
    }.also { exec ->
        exec.scheduleWithFixedDelay({ runCatching { runBlocking(playwrightDispatcher) { sweepIdle() } } }, 30, 30, TimeUnit.SECONDS)
    }

val captureDir: File

Where captures are kept as evidence: AFIELD_AI4_CAPTURE_DIR, else ~/.afield/logs/ai4_box_captures.

    val captureDir: File
        get() = System.getenv("AFIELD_AI4_CAPTURE_DIR")?.takeIf { it.isNotBlank() }?.let(::File)
            ?: File(System.getProperty("user.home"), ".afield/logs/ai4_box_captures")

fun openPages(): Int

How many capture pages are open — for tests of the release rules and for status.

    fun openPages(): Int = runBlocking(playwrightDispatcher) { held.values.count { !it.page.isClosed } }

fun isHeld(info: SessionInformation): Boolean

    fun isHeld(info: SessionInformation): Boolean = runBlocking(playwrightDispatcher) {
        held[info.filename]?.page?.isClosed == false
    }

fun capture(info: SessionInformation, uid: String, settleMs: Int = 300): BoxOutputShot

Capture uid's output area in info. Blocking; call it off the Runloop and off the Playwright thread (it marshals onto the latter, and the page needs the former to answer its websocket).

    fun capture(info: SessionInformation, uid: String, settleMs: Int = 300): BoxOutputShot {
        check(!afield.runtime.lifecycle.Runloop.isMainThread) { "BoxOutputCapture.capture blocks; call it off the Runloop" }
        val box = info.boxes[uid] ?: return BoxOutputShot.failed("No box '$uid' in ${info.documentName}.")
        val channels = box.output.retainedAreas.channels()
        if (channels.isEmpty()) return BoxOutputShot.failed(
            "'${box.name}' has no output panel: it has not displayed anything since it was last cleared. " +
                "println text is not a panel (read it with ReadBoxOutputRequest); display(\"main\", html) makes one."
        )
        if (settleMs > 0) Thread.sleep(settleMs.toLong().coerceAtMost(5_000))
        return runBlocking(playwrightDispatcher) {
            val first = runCatching { captureOnce(info, uid, channels) }
            first.getOrElse { failure ->
                // A browser reset elsewhere (the global page's recovery closes the shared browser) kills
                // this context too, and a page that stalled in prep stays stalled. Drop it and try once
                // more on a fresh one.
                lprintln("warning,ai4", "box output capture failed for $uid (${failure.message}); retrying on a fresh page")
                dropHeld(info.filename)
                runCatching { captureOnce(info, uid, channels) }.getOrElse {
                    BoxOutputShot.failed("Could not capture '${box.name}': ${it.message ?: it.javaClass.simpleName}", channels)
                }
            }
        }
    }

fun release(info: SessionInformation)

Close info's capture page, if one is open. Safe from any thread.

body — 80 lines, from line 292
    fun release(info: SessionInformation) {
        if (afield.runtime.lifecycle.Runloop.isMainThread) {
            // Never block the Runloop on the Playwright thread; closing can wait a moment.
            afield.app.server.playwrightScope.launch { dropHeld(info.filename) }
            return
        }
        runBlocking(playwrightDispatcher) { dropHeld(info.filename) }
    }

    private fun dropHeld(key: String) {
        val h = held.remove(key) ?: return
        runCatching { h.context.close() }
    }

    private fun sweepIdle() {
        val now = System.currentTimeMillis()
        held.entries.toList().forEach { (key, h) ->
            val soleClientOfScratch = h.info.ephemeral && h.info.sessions.size <= 1
            if (h.page.isClosed || !h.browser.isConnected) dropHeld(key)
            else if (now - h.lastUsed > IDLE_RELEASE_MS && !soleClientOfScratch) dropHeld(key)
        }
    }

    private fun pageFor(info: SessionInformation): Page {
        val key = info.filename
        held[key]?.let { h ->
            if (!h.page.isClosed && h.browser.isConnected) {
                h.lastUsed = System.currentTimeMillis()
                return h.page
            }
            dropHeld(key)
        }
        while (held.size >= MAX_PAGES) {
            // Prefer evicting a page whose document will not be retired by losing it.
            val victim = held.entries.minByOrNull { (_, h) ->
                (if (h.info.ephemeral && h.info.sessions.size <= 1) Long.MAX_VALUE / 2 else 0L) + h.lastUsed
            }?.key ?: break
            dropHeld(victim)
        }
        val (browser, _) = ensureSharedPlaywrightBrowser()
        val context = browser.newContext(
            Browser.NewContextOptions()
                .setViewportSize(VIEWPORT_W, VIEWPORT_H)
                .setScreenSize(VIEWPORT_W, VIEWPORT_H)
                .setDeviceScaleFactor(1.0)
                .setIgnoreHTTPSErrors(true)
        )
        val page = context.newPage()
        held[key] = Held(info, context, page, browser)
        val url = buildPlaywrightDocumentUrl(info).replace("user=playwright", "user=ai4-capture")
        page.navigate(url, Page.NavigateOptions().setTimeout(NAVIGATION_TIMEOUT_MS).setWaitUntil(WaitUntilState.COMMIT))
        page.waitForFunction(playwrightSessionReadyScript(info), null,
            Page.WaitForFunctionOptions().setTimeout(READY_TIMEOUT_MS))
        return page
    }

    private fun captureOnce(info: SessionInformation, uid: String, channels: List<String>): BoxOutputShot {
        val page = pageFor(info)
        val prep = requestBoxScreenshot(page, BoxScreenshotRequest(
            uid = uid, padding = 12.0, panIntoView = true, hideMonaco = true, openOutputs = true,
            preserveSelection = true, includeNotifications = false, expectOutputs = ExpectedOutputs.PANEL
        ))
        // A stalled page will stall again; throwing sends capture() to drop it and retry on a fresh one.
        check(!prep.message.startsWith(SCREENSHOT_PREP_STALLED)) { prep.message }
        val clip = prep.clip
        if (!prep.success || clip == null) {
            return BoxOutputShot.failed(prep.message.ifBlank { "the page could not frame the box's output" }, channels)
        }
        val views = captureViewerStates(page, uid)
        val bytes = page.screenshot(Page.ScreenshotOptions().setClip(Clip(clip.x, clip.y, clip.w, clip.h)).setTimeout(15_000.0))
        check(captureViewerStates(page, uid) == views) { "Viewer state changed during capture; capture again after it settles" }
        held[info.filename]?.lastUsed = System.currentTimeMillis()
        val image = ImageIO.read(bytes.inputStream())
            ?: return BoxOutputShot.failed("the screenshot did not decode", channels)
        val path = runCatching { keep(bytes, info, uid) }.getOrElse {
            lprintln("warning,ai4", "could not keep box capture: ${it.message}"); ""
        }
        return BoxOutputShot(true, "captured in an independent browser", image, path, channels,
            aiToolMapper.readValue(views, object : TypeReference<List<Map<String, Any?>>>() {}))
    }

These are the capture browser's states, not a guess about the human's camera or Parts choices.

    private fun captureViewerStates(page: Page, uid: String): String = page.evaluate("""
        uid => {
            const box = [...document.getElementsByClassName('box')].find(e =>
                (e.uid || e.getAttribute('uid') || e.getAttribute('data-uid')) === uid);
            const views = [];
            for (const frame of box?.querySelectorAll('.outputArea iframe') || []) {
                let w;
                try { w = frame.contentWindow; if (!w.FieldInspector) continue; }
                catch (e) { if (e.name === 'SecurityError') continue; throw e; }
                const ready = w.FieldViewer?.captureStatus?.();
                if (ready && !ready.ready) throw new Error(ready.error || 'Viewer is still preparing');
                const snapshot = w.FieldInspector.snapshot();
                const keys = ['artifactDigest','renderOrigin','camera','target','hidden','explode','xray',
                    'xrayOpacity','sectionEnabled','sectionNormal','sectionFraction','selection','tools'];
                views.push({viewId:new URL(w.location.href).searchParams.get('uid'),
                    state:Object.fromEntries(keys.filter(k => k in snapshot).map(k => [k,snapshot[k]]))});
            }
            return JSON.stringify(views);
        }
    """.trimIndent(), uid) as String

    private val stamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss-SSS").withZone(ZoneOffset.UTC)

    private fun keep(png: ByteArray, info: SessionInformation, uid: String): String {
        val dir = captureDir.apply { mkdirs() }
        val safeUid = uid.replace(Regex("[^A-Za-z0-9_-]"), "_").take(48)
        val file = File(dir, "${stamp.format(Instant.now())}-$safeUid.png")
        file.writeBytes(png)
        dir.listFiles { f -> f.name.endsWith(".png") }?.sortedByDescending { it.name }?.drop(MAX_KEPT_CAPTURES)?.forEach { it.delete() }
        return file.absolutePath
    }
}