Skip to content

HistoryScreenshotCapture.kt

src/jvmMain/kotlin/afield/document/history/HistoryScreenshotCapture.kt · 308 lines

@file:OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)

package afield.document.history

import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.tools.docs.screenshot
import afield.ui.output.history
import afield.ui.visualization.typed

import afield.app.protocol.HistoryScreenshotAdded
import afield.app.protocol.Message
import afield.app.protocol._send
import afield.app.protocol.payload
import afield.app.server.documents
import afield.audio.synth.*
import afield.data.table.ref
import afield.document.graph.box
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.Runloop
import afield.graphics.vulkan.util.openWindowsSnapshot
import afield.app.diagnostics.logging.SaveReloadLog
import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap

object HistoryScreenshotCapture

object HistoryScreenshotCapture {
    private const val DEFAULT_CAPTURE_DELAY_MS = 250L
    private const val DEFAULT_MIN_CAPTURE_INTERVAL_MS = 30_000L
    private const val DEFAULT_MAX_CAPTURE_PIXELS = 12_200_000L

    private data class PendingCapture(
        val documentPath: String,
        val versionIndex: Int,
        val timestamp: Long
    )

    private data class CapturableWindow(
        val window: afield.graphics.vulkan.util.Window,
        val pixels: Long
    )

    private val pendingCaptures = ConcurrentHashMap<String, PendingCapture>()
    private val scheduledDocuments = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
    private val lastCaptureAt = ConcurrentHashMap<String, Long>()

fun captureFourWindowsOnSave

body — 195 lines, from line 61
    fun captureFourWindowsOnSave(
        documentPath: String,
        versionIndex: Int = HistoryMetrics.currentVersionIndex(documentPath),
        timestamp: Long = System.currentTimeMillis()
    ) {
        if (!fourWindowScreenshotsEnabled()) return
        if (versionIndex < 0) {
            SaveReloadLog.log(
                "history-four-screenshot-skip",
                "doc=$documentPath reason=no-current-version versionIndex=$versionIndex"
            )
            return
        }
        pendingCaptures[documentPath] = PendingCapture(documentPath, versionIndex, timestamp)
        scheduleCapture(documentPath)
        SaveReloadLog.log(
            "history-four-screenshot-scheduled",
            "doc=$documentPath versionIndex=$versionIndex timestamp=$timestamp pending=${pendingCaptures.size}"
        )
    }

    private fun scheduleCapture(documentPath: String) {
        if (!scheduledDocuments.add(documentPath)) return
        GlobalScope.launch(Runloop.miscPool.asCoroutineDispatcher()) {
            val scheduledAt = System.nanoTime()
            var delayMs = 0L
            var minIntervalDelayMs = 0L
            try {
                delayMs = configuredMillis(
                    property = "afield.history.fourScreenshotDelayMs",
                    env = "AFIELD_HISTORY_FOUR_SCREENSHOT_DELAY_MS",
                    defaultValue = DEFAULT_CAPTURE_DELAY_MS
                )
                delay(delayMs)

                var pending = pendingCaptures.remove(documentPath) ?: return@launch
                val minInterval = configuredMillis(
                    property = "afield.history.fourScreenshotMinIntervalMs",
                    env = "AFIELD_HISTORY_FOUR_SCREENSHOT_MIN_INTERVAL_MS",
                    defaultValue = DEFAULT_MIN_CAPTURE_INTERVAL_MS
                )
                val elapsed = System.currentTimeMillis() - (lastCaptureAt[documentPath] ?: 0L)
                if (elapsed < minInterval) {
                    minIntervalDelayMs = minInterval - elapsed
                    delay(minIntervalDelayMs)
                    pending = pendingCaptures.remove(documentPath) ?: pending
                }

                lastCaptureAt[documentPath] = System.currentTimeMillis()
                SaveReloadLog.log(
                    "history-four-screenshot-start",
                    "doc=$documentPath versionIndex=${pending.versionIndex} delayMs=$delayMs minIntervalDelayMs=$minIntervalDelayMs queueWaitMs=${
                        SaveReloadLog.elapsedMillis(
                            scheduledAt
                        )
                    }"
                )
                captureNow(pending)
            } finally {
                scheduledDocuments.remove(documentPath)
                if (pendingCaptures.containsKey(documentPath)) {
                    scheduleCapture(documentPath)
                }
            }
        }
    }

    private suspend fun captureNow(pending: PendingCapture) {
        val totalStartedAt = System.nanoTime()
        val documentPath = pending.documentPath
        val versionIndex = pending.versionIndex
        val timestamp = pending.timestamp
        val windowsStartedAt = System.nanoTime()
        val maxPixels = configuredLong(
            property = "afield.history.fourScreenshotMaxPixels",
            env = "AFIELD_HISTORY_FOUR_SCREENSHOT_MAX_PIXELS",
            defaultValue = DEFAULT_MAX_CAPTURE_PIXELS
        )
        var unsupportedCount = 0
        var oversizedCount = 0
        var foreignCount = 0
        // Only capture windows that belong to the document being saved. Windows are process-global,
        // so without this a save of document A would snapshot document B's renderer window (or a
        // leaked/stale window) into A's history — the root of the four-window / four-window-1 /
        // four-window-2 channel fragmentation. When we can't resolve the document's own windows
        // (e.g. its renderer isn't held as a box property), fall back to all open windows so we
        // never regress capture for documents that don't follow the convention.
        val ownedWindows = documentOwnedWindows(documentPath)
        val windows = openWindowsSnapshot().mapNotNull { window ->
            if (ownedWindows.isNotEmpty() && window !in ownedWindows) {
                foreignCount++
                return@mapNotNull null
            }
            val renderer = window.associatedRenderer
            if (renderer == null || !renderer.supportsDirectWindowCapture(window)) {
                unsupportedCount++
                return@mapNotNull null
            }
            val pixels = window.width.toLong() * window.height.toLong()
            if (maxPixels > 0L && pixels > maxPixels) {
                oversizedCount++
                SaveReloadLog.log(
                    "history-four-screenshot-skip",
                    "doc=$documentPath versionIndex=$versionIndex reason=oversized-window size=${window.width}x${window.height} pixels=$pixels maxPixels=$maxPixels"
                )
                return@mapNotNull null
            }
            CapturableWindow(window, pixels)
        }
        val windowsMs = SaveReloadLog.elapsedMillis(windowsStartedAt)
        if (windows.isEmpty()) {
            SaveReloadLog.logTiming(
                "history-four-screenshot-capture",
                totalStartedAt,
                "doc=$documentPath versionIndex=$versionIndex windows=0 owned=${ownedWindows.size} foreign=$foreignCount unsupported=$unsupportedCount oversized=$oversizedCount maxPixels=$maxPixels windowsMs=$windowsMs"
            )
            return
        }

        windows.forEachIndexed { index, candidate ->
            val window = candidate.window
            val windowStartedAt = System.nanoTime()
            val renderer = window.associatedRenderer ?: return@forEachIndexed
            val channel = if (windows.size == 1) "four-window" else "four-window-${index + 1}"
            var reserveMs = 0L
            var captureMs = 0L
            var addRefMs = 0L
            var metricsRefWritten = false
            var sendMs = 0L
            var ok = false
            var error = ""
            val reserveStartedAt = System.nanoTime()
            val reserved = HistoryMetrics.reserveScreenshotFile(
                documentPath = documentPath,
                versionIndex = versionIndex,
                channel = channel,
                timestamp = timestamp + index,
                width = window.width,
                height = window.height,
                extension = "jpg"
            )
            reserveMs = SaveReloadLog.elapsedMillis(reserveStartedAt)
            if (reserved == null) {
                SaveReloadLog.log(
                    "history-four-screenshot-window",
                    "ms=${SaveReloadLog.elapsedMillis(windowStartedAt)} doc=$documentPath versionIndex=$versionIndex channel=$channel ok=false reason=reserve-failed reserveMs=$reserveMs size=${window.width}x${window.height}"
                )
                return@forEachIndexed
            }
            val (file, ref) = reserved
            runCatching {
                val captureStartedAt = System.nanoTime()
                renderer.captureWindowToFile(window, file.path)
                captureMs = SaveReloadLog.elapsedMillis(captureStartedAt)
                if (fourWindowScreenshotMetricsEnabled()) {
                    val addRefStartedAt = System.nanoTime()
                    HistoryMetrics.addScreenshotRef(documentPath, versionIndex, ref)
                    addRefMs = SaveReloadLog.elapsedMillis(addRefStartedAt)
                    metricsRefWritten = true
                }
                val sendStartedAt = System.nanoTime()
                _send(
                    Message("history-screenshot-added").payload(
                        HistoryScreenshotAdded(versionIndex, ref)
                    ),
                    document = Regex.escape(documentPath)
                )
                sendMs = SaveReloadLog.elapsedMillis(sendStartedAt)
                ok = true
            }.onFailure {
                error = it.message ?: it::class.simpleName ?: "unknown"
                lprintln(
                    "warning,history",
                    "Failed to capture $channel for history version $versionIndex: ${it.message}"
                )
            }.also {
                val windowMs = SaveReloadLog.elapsedMillis(windowStartedAt)
                SaveReloadLog.log(
                    "history-four-screenshot-window",
                    "ms=$windowMs doc=$documentPath versionIndex=$versionIndex channel=$channel ok=$ok error=$error reserveMs=$reserveMs captureMs=$captureMs addRefMs=$addRefMs metricsRefWritten=$metricsRefWritten sendMs=$sendMs size=${window.width}x${window.height} pixels=${candidate.pixels} maxPixels=$maxPixels path=${ref.path}"
                )
                if (windowMs >= 1000L) {
                    lprintln(
                        "warning,history",
                        "slow four screenshot doc=$documentPath channel=$channel ms=$windowMs captureMs=$captureMs addRefMs=$addRefMs"
                    )
                }
            }
        }
        SaveReloadLog.logTiming(
            "history-four-screenshot-capture",
            totalStartedAt,
            "doc=$documentPath versionIndex=$versionIndex windows=${windows.size} owned=${ownedWindows.size} foreign=$foreignCount unsupported=$unsupportedCount oversized=$oversizedCount maxPixels=$maxPixels windowsMs=$windowsMs"
        )
    }

The set of Field 4 windows owned by documentPath, discovered by scanning that document's boxes for Renderer-typed property values (renderers are held as box properties; see agent_backdoor's getPropertyRaw("renderer")) and collecting their windows. Naming- agnostic — matches any property that holds a Renderer, not just "renderer". Returns empty when the document or its renderers can't be resolved, which the caller treats as "capture all open windows" (prior behavior).

source — 45 lines, from line 263
    private fun documentOwnedWindows(documentPath: String): Set<afield.graphics.vulkan.util.Window> {
        val sessionInfo = documents.values.firstOrNull {
            it.filename == documentPath ||
                    it.documentName == documentPath ||
                    java.io.File(it.filename).name == documentPath
        } ?: return emptySet()
        val windows = LinkedHashSet<afield.graphics.vulkan.util.Window>()
        runCatching {
            sessionInfo.boxes.values.toList().forEach { box ->
                box.properties.values.toList().forEach { value ->
                    if (value is afield.graphics.vulkan.Renderer) windows += value.windows
                }
            }
        }
        return windows
    }

    private fun fourWindowScreenshotsEnabled(): Boolean {
        val configured = System.getProperty("afield.history.fourScreenshots")
            ?: System.getenv("AFIELD_HISTORY_FOUR_SCREENSHOTS")
            ?: return true
        return !(configured.equals("false", ignoreCase = true) ||
                configured.equals("no", ignoreCase = true) ||
                configured == "0")
    }

    private fun fourWindowScreenshotMetricsEnabled(): Boolean {
        val configured = System.getProperty("afield.history.fourScreenshotWriteMetrics")
            ?: System.getenv("AFIELD_HISTORY_FOUR_SCREENSHOT_WRITE_METRICS")
            ?: return false
        return configured.equals("true", ignoreCase = true) ||
                configured.equals("yes", ignoreCase = true) ||
                configured == "1"
    }

    private fun configuredMillis(property: String, env: String, defaultValue: Long): Long {
        val value = System.getProperty(property) ?: System.getenv(env)
        return value?.toLongOrNull()?.coerceAtLeast(0L) ?: defaultValue
    }

    private fun configuredLong(property: String, env: String, defaultValue: Long): Long {
        val value = System.getProperty(property) ?: System.getenv(env)
        return value?.toLongOrNull()?.coerceAtLeast(0L) ?: defaultValue
    }
}