pageFilm.kt
src/jvmMain/kotlin/afield/tools/docs/pageFilm.kt · 115 lines
Filming any web page frame by frame: the page filmer behind film-page on the backdoor.
The canvas flight (recordCanvasFlight) drives Field's own canvas. Some things worth filming are
other pages: a chat lens exported to a standalone HTML file, a three.js view on its own page. filmPage
opens the page in a capture context of its own (so the shared Field page is untouched), runs a setup
script, then steps a frame script through the film's clock, waiting for each frame to paint before it
captures it and pipes it to ffmpeg, exactly as the flight does. Each script's answer is JSON text
("null" for none): evaluateWithin waits for a truthy answer, and an undefined would run it again.
package afield.tools.docs
import afield.app.diagnostics.logging.lprintln
import afield.app.protocol.AgentPageFilm
import afield.app.protocol.AgentPageFilmResponse
import afield.app.server.ensureSharedPlaywrightBrowser
import afield.app.server.playwrightDispatcher
import com.google.gson.JsonObject
import com.microsoft.playwright.Browser
import com.microsoft.playwright.options.ViewportSize
import kotlinx.coroutines.withContext
import java.nio.file.Files
import java.nio.file.Path
import java.util.Base64
import kotlin.math.roundToInt
private const val PAGE_FRAME_TIMEOUT_MS = 30_000
suspend fun filmPage(request: AgentPageFilm): AgentPageFilmResponse
Films request's page to its path. Runs on the Playwright thread.
body — 84 lines, from line 31
suspend fun filmPage(request: AgentPageFilm): AgentPageFilmResponse = withContext(playwrightDispatcher) {
fun failed(message: String) = AgentPageFilmResponse(ok = false, url = request.url, path = request.path, message = message)
if (!request.durationSec.isFinite() || request.durationSec <= 0.0 || request.durationSec > 3600.0)
return@withContext failed("durationSec must be more than 0 and at most 3600, got ${request.durationSec}")
if (request.fps !in 1..120) return@withContext failed("fps must be 1 to 120, got ${request.fps}")
if (request.width !in 64..7680 || request.height !in 64..4320) return@withContext failed("a film is 64x64 to 7680x4320 pixels")
if (!request.pixelRatio.isFinite() || request.pixelRatio !in 0.5..4.0) return@withContext failed("pixelRatio must be 0.5 to 4")
runCatching { film(ensureSharedPlaywrightBrowser().first, request) }.getOrElse {
lprintln("warning,video", "Page film of ${request.url} failed: ${it.message}")
failed(it.message ?: it::class.simpleName ?: "page film failed")
}
}
private fun film(browser: Browser, request: AgentPageFilm): AgentPageFilmResponse {
val output = Path.of(request.path).toAbsolutePath()
output.parent?.let { Files.createDirectories(it) }
val context = browser.newContext(
Browser.NewContextOptions()
.setViewportSize(ViewportSize((request.width / request.pixelRatio).roundToInt(), (request.height / request.pixelRatio).roundToInt()))
.setDeviceScaleFactor(request.pixelRatio)
.setIgnoreHTTPSErrors(true)
)
try {
val page = context.newPage()
page.navigate(request.url, com.microsoft.playwright.Page.NavigateOptions().setTimeout(request.loadTimeoutMs.toDouble()))
val setupResult = request.setup?.let {
evaluateWithin(page, "async () => JSON.stringify(await ($it)()) ?? \"null\"", null, request.loadTimeoutMs, "page film: setup")?.toString()
}
if (request.settleMs > 0) page.waitForTimeout(request.settleMs.toDouble())
awaitPaintedFrames(page, "page film: first paint")
val frames = canvasFlightFrameCount(request.durationSec, request.fps)
val cdp = context.newCDPSession(page)
var lastFrameResult: String? = null
val encoder = FlightEncoder(output, request.fps, request.width, request.height)
val dragging = request.dragDx != 0.0 || request.dragDy != 0.0
val viewport = page.viewportSize()
val x0 = request.dragX ?: (viewport.width / 2.0)
val y0 = request.dragY ?: (viewport.height / 2.0)
try {
if (dragging) {
page.mouse().move(x0, y0)
page.mouse().down()
}
val preroll = request.prerollFrames.coerceIn(0, 200_000)
for (step in 0 until preroll + frames) {
val i = step - preroll
val filming = i >= 0
val t = if (filming) canvasFlightFrameTime(i, frames, request.fps, request.durationSec) else i.toDouble() / request.fps
if (request.frame != null) {
lastFrameResult = evaluateWithin(
page, "async (a) => JSON.stringify(await (${request.frame})(a.t, a.i, a.n)) ?? \"null\"",
mapOf("t" to t, "i" to i, "n" to frames), PAGE_FRAME_TIMEOUT_MS, "page film: frame ${i + 1} of $frames"
)?.toString()
}
if (dragging) page.mouse().move(x0 + request.dragDx * step, y0 + request.dragDy * step)
awaitPaintedFrames(page, "page film: painting frame ${i + 1} of $frames")
if (!filming) continue
val shot = cdp.send("Page.captureScreenshot", JsonObject().apply {
addProperty("format", "jpeg"); addProperty("quality", 92); addProperty("captureBeyondViewport", false)
})
encoder.write(Base64.getDecoder().decode(shot.get("data").asString))
if ((i + 1) % (request.fps * 5) == 0) lprintln("info,video", "Page film: ${i + 1} of $frames frames")
}
if (dragging) page.mouse().up()
} catch (t: Throwable) {
encoder.abandon()
throw t
} finally {
runCatching { cdp.detach() }
}
encoder.close()
val encoded = countEncodedFrames(output)
val ok = encoded == null || encoded == frames
lprintln("info,video", "Page film: filmed $frames frames of ${request.url} to $output")
return AgentPageFilmResponse(
ok = ok, url = request.url, path = output.toString(), durationSec = request.durationSec, fps = request.fps,
frameCount = frames, encodedFrames = encoded, width = request.width, height = request.height,
setupResult = setupResult, lastFrameResult = lastFrameResult,
message = if (ok) "Filmed $frames frames to $output" else "the file holds $encoded frames, $frames were filmed"
)
} finally {
runCatching { context.close() }
}
}