Sweeping saved keyframes overnight
The declarations used below are listed, with their KDoc, in the codebase reference:
keyframeSweep.kt and
keyframeSweepCapture.kt. This page assumes
Capturing & Replaying Live State, which is where a KeyframeScope, a Key
and interpolate come from.
A KeyframeScope gives you one look at a time: capture it, edit it, put it back. A directory of
saved keys describes more than the looks in it: twelve keys with ten lanes each are twelve points
in a ten-dimensional space, and almost all of that space is blends nobody has tried.
KeyframeSweep visits the rest of it overnight. Each pass draws a few keys from the pool, blends
them at random weights — sometimes past either end of the hull they span — applies the blend to the
live scope, makes a directory, and hands that directory to a suspend callback of yours, which runs
to completion before the next pass starts. What the callback writes there is its business: a still, a
short clip, a mesh, a CSV. What the sweep writes there is the provenance — the blended key as
ordinary key JSON, and a manifest saying which keys went in at which weight.
A night of this is a few thousand directories to categorise, cluster, score or hand to a model, and the layout below is designed so that a second program can do that.
The whole of it
This is longer than most examples on these pages, because we are going to build all of it in place,
in one box: a small scene to sweep over, a pool of saved keys, the sweep itself, and the morning's
look at what it made. In your own document those would be separate boxes, and the scene and the keys
would already exist. The box sits under a renderer parent, so renderer is already in its preamble.
Two folds hold the scene and the pool. The sweep is the dozen lines between them and the last fold,
which reads the archive back.
:::fold imports
import afield.graphics.scene.fabricated.StandardRenderer
import afield.graphics.scene.fabricated.applyScenePlan
import java.nio.file.Files
import java.util.Base64
import kotlin.io.path.readBytes
import kotlin.io.path.writeText
:::endfold
val dim = 420
:::fold the scene, and the ten lanes of it that a key can drive
/*
The half you would replace with your own sketch: a grid of spheres on a floor, and a
`KeyframeScope` over ten places in it. Nothing below this block ever writes those ten places —
only a blended key does, which is what makes the contact sheet evidence rather than decoration.
*/
val bundle = StandardRenderer.build(
renderer,
StandardRenderer.Config(renderWidth = dim, renderHeight = dim, backgroundColor = Vector3f(0.02f, 0.02f, 0.03f)),
)
val scene = bundle.scene
/*
A square target must be projected square. `Projection(renderer)`'s `auto` lambda re-resizes to the
*window* inside every evaluateUniform, so a one-off `resize` is overwritten on the next frame and
every sphere comes out an ellipse. Replace the lambda; do not call `resize` once.
*/
scene.projection.auto = { scene.projection.resize(dim, dim) }
scene.projection.resize(dim, dim)
scene.camera.at.set(0f, 2.0f, -3.2f)
scene.camera.target.set(0f, 0f, 0f)
scene.lightDirection.set(Vector3f(-0.5f, -1f, -0.3f).normalize())
scene.lightIntensity = 3.4f
val grid = 3
val spheres = (0 until grid * grid).map {
bundle.registerSphere(name = "ball-$it", radius = 0.3f, slices = 24, stacks = 24, renderer = renderer)
}
/*
A floor, and not for decoration: `shadow.strength` and `ao.strength` are two of the ten lanes, and
with nothing under the spheres to receive a shadow they are two lanes a sweep could move all night
without changing a pixel.
*/
bundle
.registerCube(name = "floor", size = 1f, renderer = renderer)
.setModel(Matrix4f().translate(0f, -0.34f, 0f).scale(11f, 0.04f, 11f))
bundle.applyScenePlan()
var spread = 0.85f
var visible = grid * grid
// Hidden from the outside in. Hiding the tail of a row-major grid takes its last row away and shoves
// the rest off to one side, which would make `grid.visible` half a camera move as well as a count.
val centreOut = (0 until grid * grid).sortedBy {
val ix = it / grid - (grid - 1) / 2
val iz = it % grid - (grid - 1) / 2
ix * ix + iz * iz
}
val layout = {
centreOut.forEachIndexed { rank, i ->
val ix = i / grid - (grid - 1) / 2
val iz = i % grid - (grid - 1) / 2
spheres[i].setModel(
if (rank >= visible) Matrix4f().scale(0f) else Matrix4f().translate(ix * spread, 0f, iz * spread),
)
}
}
layout()
val palettes = mapOf(
"warm" to (Vector3f(0.10f, 0.05f, 0.03f) to Vector3f(1.0f, 0.78f, 0.55f)),
"cool" to (Vector3f(0.03f, 0.05f, 0.11f) to Vector3f(0.62f, 0.78f, 1.0f)),
"acid" to (Vector3f(0.04f, 0.10f, 0.04f) to Vector3f(0.75f, 1.0f, 0.35f)),
)
var palette = "warm"
val applyPalette = { name: String ->
val chosen = palettes[name] ?: palettes.getValue("warm")
scene.ambientColor.set(chosen.first)
scene.lightColor.set(chosen.second)
palette = name
}
applyPalette(palette)
val keys = KeyframeScope(schemaVersion = 1).apply {
vector3Target("camera.at", { scene.camera.at }) { scene.camera.at.set(it) }
vector3Target("camera.target", { scene.camera.target }) { scene.camera.target.set(it) }
vector3Target("light.direction", { scene.lightDirection }) {
scene.lightDirection.set(if (it.lengthSquared() > 1e-8f) Vector3f(it).normalize() else it)
}
floatTarget("light.intensity", { scene.lightIntensity }) { scene.lightIntensity = it }
vector3Target("background", { scene.backgroundColor }) { scene.backgroundColor.set(it) }
floatTarget("ao.strength", { scene.aoStrength }) { scene.aoStrength = it }
floatTarget("shadow.strength", { scene.shadowStrength }) { scene.shadowStrength = it }
floatTarget("grid.spread", { spread }) {
spread = it
layout()
}
// An `Int` lane, because a subdivision level or an instance count is one, and it interpolates
// as a float lane so it can travel smoothly between two looks.
intTarget("grid.visible", { visible }, 1..(grid * grid)) {
visible = it
layout()
}
// The discrete lane: a name that selects a whole sub-state. There is no midpoint between "warm"
// and "acid", which is the case the sweep has to have a policy about.
stringTarget("palette", { palette }) { applyPalette(it) }
}
bundle.present(renderer = renderer)
bundle.attachTo(renderer)
:::endfold
:::fold four keys, as a few evenings of clicking would have left them
/*
In your own document these are files you saved with `scope.capture().save(…)`. The fourth is
deliberately *incomplete* — it predates two of the lanes, the way a key saved three weeks ago
does — because that is the case the blend has to have an answer for.
*/
val pool = Files.createTempDirectory("autosweep-pool")
val look = { name: String, values: Map<String, Any?> ->
pool.resolve(name).writeText(keys.encodeKeyJson(keys.Key(values.toMutableMap())))
}
look(
"high-and-cool",
mapOf(
"camera.at" to Vector3f(0f, 3.0f, -3.0f),
"camera.target" to Vector3f(0f, 0f, 0f),
"light.direction" to Vector3f(-0.5f, -1f, -0.3f).normalize(),
"light.intensity" to 3.4f,
"background" to Vector3f(0.012f, 0.020f, 0.050f),
"ao.strength" to 0.85f,
"shadow.strength" to 1.0f,
"grid.spread" to 0.85f,
"grid.visible" to 9,
"palette" to "cool",
),
)
look(
"low-and-warm",
mapOf(
"camera.at" to Vector3f(1.8f, 1.2f, -2.6f),
"camera.target" to Vector3f(0f, 0.1f, 0f),
"light.direction" to Vector3f(0.7f, -0.4f, -0.5f).normalize(),
"light.intensity" to 6.2f,
"background" to Vector3f(0.050f, 0.025f, 0.012f),
"ao.strength" to 0.35f,
"shadow.strength" to 0.6f,
"grid.spread" to 1.45f,
"grid.visible" to 4,
"palette" to "warm",
),
)
look(
"flat-and-acid",
mapOf(
"camera.at" to Vector3f(-2.2f, 1.8f, -2.4f),
"camera.target" to Vector3f(0.2f, 0.2f, 0f),
"light.direction" to Vector3f(-0.1f, -0.95f, 0.1f).normalize(),
"light.intensity" to 1.9f,
"background" to Vector3f(0.010f, 0.045f, 0.015f),
"ao.strength" to 1.0f,
"shadow.strength" to 0.15f,
"grid.spread" to 0.55f,
"grid.visible" to 9,
"palette" to "acid",
),
)
look(
"older-partial-key",
mapOf(
"camera.at" to Vector3f(0f, 1.6f, -3.6f),
"camera.target" to Vector3f(0f, 0f, 0f),
"light.direction" to Vector3f(-0.8f, -0.6f, 0.1f).normalize(),
"light.intensity" to 4.6f,
"background" to Vector3f(0.020f, 0.020f, 0.020f),
"shadow.strength" to 0.8f,
"grid.spread" to 1.05f,
"grid.visible" to 6,
),
)
:::endfold
// ── and here is the sweep. Everything above is the sketch; this is the whole feature. ──
val archive = Files.createTempDirectory("autosweep-archive")
val sweep = KeyframeSweep(
keys,
pool,
archive,
SweepConfig(
seed = 20260916,
count = 8,
minArity = 2,
maxArity = 3,
// Below 1, a blend is mostly one key with the others as perturbation. Well above it, every
// blend is the average of its participants — a night of mush.
concentration = 0.7,
extrapolationProbability = 0.3,
),
)
/*
`frames { }` puts the run on the Runloop, so the callback can wait for real frames and the stop
key cancels it between renders. (`frames` rather than `start`: `start` has a receiver overload and
a receiverless one, and a bare lambda satisfies both, so `start { … }` does not compile anywhere —
every existing call site passes a callable reference instead, which is why nobody has hit it.)
*/
frames {
val report = sweep.run { render ->
// The blend is already applied when this is called. Wait for it to be drawn, then read the
// target back into the render's own directory.
renderer.awaitFrames(6)
render.captureStill(renderer, bundle.postStack.output(), settleFrames = 0)
}
println("$report")
:::fold the morning: read the archive back and show what went into each render
// Nothing here touches the renderer or the scope. An archive describes itself, which is the
// whole point of the manifests — this is the code an offline categorising pass starts from.
val rows = readSweepArchive(archive)
val sheet =
rows.joinToString("") { m ->
val still = archive.resolve("renders/${m.id}/still.png")
val img =
"<img src='data:image/png;base64,${Base64.getEncoder().encodeToString(still.readBytes())}' " +
"width='120' style='border:1px solid #333;display:block'/>"
"<div style='display:inline-block;margin:2px;font:10px Iosevka,monospace;color:#999'>" +
"$img${m.index}${m.extrapolationGain?.let { " ✦" } ?: ""} ${m.anchor.take(12)}</div>"
}
display("sheet", "<div style='width:520px'>$sheet</div>")
markdown(
"what went in",
"""
| # | keys blended, and at what weight | pushed past the hull | `palette` taken from |
|---|----------------------------------|----------------------|----------------------|
${rows.joinToString("\n") { m ->
"| ${m.index} | ${m.parents.joinToString("<br>") { "${it.key} ${it.weight.f2}" }} " +
"| ${m.extrapolationGain?.let { g -> "x${g.f2} away from<br>${m.extrapolationPole}" } ?: "—"} " +
"| ${m.discreteLanes.values.distinct().joinToString(", ")} |"
}}
""",
width = 520,
)
:::endfold
}

Read the table against the sheet. Render 0 is low-and-warm at 1.10, high-and-cool at 0.17 and
older-partial-key at −0.27: the weights still sum to 1, and one of them is negative, which is
what "outside the hull" means. Nobody authored that look, and nothing in the pool is a fifth of the
way past older-partial-key in the direction of the other two.
What a sweep leaves on disk
archive/
sweep.json the run's own configuration, pool census and outcome
index.jsonl one compact manifest per render — the file an analysis pass reads
renders/000123-4f2a9c/
key.json the blend, as ordinary key JSON
manifest.json parents and weights, the anchor, the pole, back-fills, artifacts, timings
still.png whatever your callback wrote
clip.mp4
frames/
A second pass relies on these properties of the layout.
key.json is an ordinary key, written by the same serializer saveKey uses, so
scope.loadKey(…) reads it and KeyframeCollection browses it. Which means renders/ is itself a
valid pool: point tonight's pool at last night's renders with pattern = "*/key.json", and the
space explores its own interesting regions rather than the original twelve labels. It is written flat
— no .versions history — through
encodeKeyJson, because saveKey is always additive and ten
thousand write-once renders do not want ten thousand one-entry histories.
manifest.json is the render's whole provenance, and it is written before the callback runs and
rewritten after. A manifest still reading "status": "running" is a render whose process died inside
it, which is the distinction a resume needs.
index.jsonl is a convenient index of the manifests. It is appended as renders finish, so a process
killed between a manifest write and its append leaves it one line short.
readSweepArchive falls back to walking the manifests, and
KeyframeSweep.reindex rebuilds it; the manifests are
authoritative.
How several keys are blended
KeyframeScope.interpolate already knows how every lane type blends — numbers and vectors lerp,
quaternions slerp, CameraPose sclerps, code blends or steps. An N-way blend here is therefore a
pairwise fold over it rather than a second type table that would immediately drift from the first.
With weights sorted descending and running sums S, folding t = w / S at each step gives exactly
the affine weighted mean for every linear lane.
Sorting descending is what makes that safe rather than merely tidy. The weights sum to 1, so the
running sums start at the largest weight and end at 1 — rising while the terms are positive, falling
once they go negative — and the minimum is min(w_max, 1), which is always positive. No divisor ever
approaches zero, including under extrapolation, where some weights are negative.
Picking the three lane policies in SweepConfig
Three kinds of lane need a policy the fold cannot supply. Each is a field on
SweepConfig, and each is recorded per render.
Lanes with no midpoint — a string, or a code block. interpolate steps them to the
right-hand value once t >= 1, and inside a fold t = w / S can exceed 1 whenever the weights
have mixed signs, which is what extrapolation produces. DiscreteBlend.ANCHOR gives every
discrete lane to one parent — the anchor, drawn by roulette over the final weights, so the
key dominating the picture usually supplies the code — and the manifest names it.
ROULETTE_PER_LANE draws each lane independently, which recombines code across keys and breaks
any pair of lanes that had to agree. AS_INTERPOLATED leaves the fold's answer: pick it when the
scope has a codeBlend configured and arity is 2, because then the fold really is a code blend.
Participants that disagree about which lanes exist, which a pool accumulating over weeks
guarantees. interpolate drops any name the two sides do not share, so a naive fold shrinks to
the intersection of everything it touched and the oldest key in the pool decides how much of the
look gets explored. MissingSlots.FILL_FROM_LIVE back-fills from scope.capture() and lists
every fill in the manifest; FILL_FROM_ANCHOR, INTERSECT and REQUIRE_ALL are the
alternatives, and REQUIRE_ALL reports how many keys it excluded.
Blending past the saved keys
An extrapolated look is the one nobody would have authored: past the end of every key you saved rather
than between them. With probability extrapolationProbability, one participant is drawn as the
pole — the key the blend is pushed away from — and the other weights are scaled so that the pole's
own weight becomes exactly 1 - gain.
That makes gain read directly. At 1.0 the pole's weight is zero and the blend sits exactly on the
far face of the hull; at 1.5 it is half as far again beyond that face; anything above 1 is genuinely
outside. minExtrapolationGain is therefore floored at 1.0, because a "gain" that sometimes
extrapolated and sometimes did not would make the archive's extrapolationGain field mean nothing.
The pole is a different draw from the anchor. One key doing both jobs produces blends wearing the
palette of the look they have just fled; keeping them separate is visible in the table above as a
render whose palette comes from the key with the largest weight while the pole is the one with the
negative one.
And a blend already dominated by one key is left convex. The nearer the pole's weight is to 1, the
larger the push needed to leave the hull at all, and past about 0.9 that push is a blast rather than
an exploration. Such a render keeps convex weights, and the null extrapolationGain in its manifest
says so.
The figure below makes three renders from the same three keys with the same base weights, using
SweepConfig.draws to choose each render instead of drawing it: once convex, then pushed away from
flat-and-acid at gain 1.0 and at 1.5. The scene and keys are the ones above.
:::fold imports
import afield.graphics.scene.fabricated.StandardRenderer
import afield.graphics.scene.fabricated.applyScenePlan
import java.nio.file.Files
import java.util.Base64
import kotlin.io.path.readBytes
import kotlin.io.path.writeText
:::endfold
val dim = 420
:::fold the same scene and lanes as above
/*
The half you would replace with your own sketch: a grid of spheres on a floor, and a
`KeyframeScope` over ten places in it. Nothing below this block ever writes those ten places —
only a blended key does, which is what makes the contact sheet evidence rather than decoration.
*/
val bundle = StandardRenderer.build(
renderer,
StandardRenderer.Config(renderWidth = dim, renderHeight = dim, backgroundColor = Vector3f(0.02f, 0.02f, 0.03f)),
)
val scene = bundle.scene
/*
A square target must be projected square. `Projection(renderer)`'s `auto` lambda re-resizes to the
*window* inside every evaluateUniform, so a one-off `resize` is overwritten on the next frame and
every sphere comes out an ellipse. Replace the lambda; do not call `resize` once.
*/
scene.projection.auto = { scene.projection.resize(dim, dim) }
scene.projection.resize(dim, dim)
scene.camera.at.set(0f, 2.0f, -3.2f)
scene.camera.target.set(0f, 0f, 0f)
scene.lightDirection.set(Vector3f(-0.5f, -1f, -0.3f).normalize())
scene.lightIntensity = 3.4f
val grid = 3
val spheres = (0 until grid * grid).map {
bundle.registerSphere(name = "ball-$it", radius = 0.3f, slices = 24, stacks = 24, renderer = renderer)
}
/*
A floor, and not for decoration: `shadow.strength` and `ao.strength` are two of the ten lanes, and
with nothing under the spheres to receive a shadow they are two lanes a sweep could move all night
without changing a pixel.
*/
bundle
.registerCube(name = "floor", size = 1f, renderer = renderer)
.setModel(Matrix4f().translate(0f, -0.34f, 0f).scale(11f, 0.04f, 11f))
bundle.applyScenePlan()
var spread = 0.85f
var visible = grid * grid
// Hidden from the outside in. Hiding the tail of a row-major grid takes its last row away and shoves
// the rest off to one side, which would make `grid.visible` half a camera move as well as a count.
val centreOut = (0 until grid * grid).sortedBy {
val ix = it / grid - (grid - 1) / 2
val iz = it % grid - (grid - 1) / 2
ix * ix + iz * iz
}
val layout = {
centreOut.forEachIndexed { rank, i ->
val ix = i / grid - (grid - 1) / 2
val iz = i % grid - (grid - 1) / 2
spheres[i].setModel(
if (rank >= visible) Matrix4f().scale(0f) else Matrix4f().translate(ix * spread, 0f, iz * spread),
)
}
}
layout()
val palettes = mapOf(
"warm" to (Vector3f(0.10f, 0.05f, 0.03f) to Vector3f(1.0f, 0.78f, 0.55f)),
"cool" to (Vector3f(0.03f, 0.05f, 0.11f) to Vector3f(0.62f, 0.78f, 1.0f)),
"acid" to (Vector3f(0.04f, 0.10f, 0.04f) to Vector3f(0.75f, 1.0f, 0.35f)),
)
var palette = "warm"
val applyPalette = { name: String ->
val chosen = palettes[name] ?: palettes.getValue("warm")
scene.ambientColor.set(chosen.first)
scene.lightColor.set(chosen.second)
palette = name
}
applyPalette(palette)
val keys = KeyframeScope(schemaVersion = 1).apply {
vector3Target("camera.at", { scene.camera.at }) { scene.camera.at.set(it) }
vector3Target("camera.target", { scene.camera.target }) { scene.camera.target.set(it) }
vector3Target("light.direction", { scene.lightDirection }) {
scene.lightDirection.set(if (it.lengthSquared() > 1e-8f) Vector3f(it).normalize() else it)
}
floatTarget("light.intensity", { scene.lightIntensity }) { scene.lightIntensity = it }
vector3Target("background", { scene.backgroundColor }) { scene.backgroundColor.set(it) }
floatTarget("ao.strength", { scene.aoStrength }) { scene.aoStrength = it }
floatTarget("shadow.strength", { scene.shadowStrength }) { scene.shadowStrength = it }
floatTarget("grid.spread", { spread }) {
spread = it
layout()
}
// An `Int` lane, because a subdivision level or an instance count is one, and it interpolates
// as a float lane so it can travel smoothly between two looks.
intTarget("grid.visible", { visible }, 1..(grid * grid)) {
visible = it
layout()
}
// The discrete lane: a name that selects a whole sub-state. There is no midpoint between "warm"
// and "acid", which is the case the sweep has to have a policy about.
stringTarget("palette", { palette }) { applyPalette(it) }
}
bundle.present(renderer = renderer)
bundle.attachTo(renderer)
:::endfold
:::fold the same four keys
/*
In your own document these are files you saved with `scope.capture().save(…)`. The fourth is
deliberately *incomplete* — it predates two of the lanes, the way a key saved three weeks ago
does — because that is the case the blend has to have an answer for.
*/
val pool = Files.createTempDirectory("autosweep-pool")
val look = { name: String, values: Map<String, Any?> ->
pool.resolve(name).writeText(keys.encodeKeyJson(keys.Key(values.toMutableMap())))
}
look(
"high-and-cool",
mapOf(
"camera.at" to Vector3f(0f, 3.0f, -3.0f),
"camera.target" to Vector3f(0f, 0f, 0f),
"light.direction" to Vector3f(-0.5f, -1f, -0.3f).normalize(),
"light.intensity" to 3.4f,
"background" to Vector3f(0.012f, 0.020f, 0.050f),
"ao.strength" to 0.85f,
"shadow.strength" to 1.0f,
"grid.spread" to 0.85f,
"grid.visible" to 9,
"palette" to "cool",
),
)
look(
"low-and-warm",
mapOf(
"camera.at" to Vector3f(1.8f, 1.2f, -2.6f),
"camera.target" to Vector3f(0f, 0.1f, 0f),
"light.direction" to Vector3f(0.7f, -0.4f, -0.5f).normalize(),
"light.intensity" to 6.2f,
"background" to Vector3f(0.050f, 0.025f, 0.012f),
"ao.strength" to 0.35f,
"shadow.strength" to 0.6f,
"grid.spread" to 1.45f,
"grid.visible" to 4,
"palette" to "warm",
),
)
look(
"flat-and-acid",
mapOf(
"camera.at" to Vector3f(-2.2f, 1.8f, -2.4f),
"camera.target" to Vector3f(0.2f, 0.2f, 0f),
"light.direction" to Vector3f(-0.1f, -0.95f, 0.1f).normalize(),
"light.intensity" to 1.9f,
"background" to Vector3f(0.010f, 0.045f, 0.015f),
"ao.strength" to 1.0f,
"shadow.strength" to 0.15f,
"grid.spread" to 0.55f,
"grid.visible" to 9,
"palette" to "acid",
),
)
look(
"older-partial-key",
mapOf(
"camera.at" to Vector3f(0f, 1.6f, -3.6f),
"camera.target" to Vector3f(0f, 0f, 0f),
"light.direction" to Vector3f(-0.8f, -0.6f, 0.1f).normalize(),
"light.intensity" to 4.6f,
"background" to Vector3f(0.020f, 0.020f, 0.020f),
"shadow.strength" to 0.8f,
"grid.spread" to 1.05f,
"grid.visible" to 6,
),
)
:::endfold
// Three keys at fixed weights, then the same blend pushed away from flat-and-acid twice.
val blend = ["high-and-cool", "low-and-warm", "flat-and-acid"]
val base = [0.4, 0.35, 0.25]
val archive = Files.createTempDirectory("autosweep-past-the-hull")
val sweep = KeyframeSweep(
keys,
pool,
archive,
SweepConfig(
draws = [
SweepDraw(blend, base),
SweepDraw(blend, base, pole = "flat-and-acid", gain = 1.0),
SweepDraw(blend, base, pole = "flat-and-acid", gain = 1.5),
],
),
)
frames {
sweep.run { render ->
renderer.awaitFrames(6)
render.captureStill(renderer, bundle.postStack.output(), settleFrames = 0)
}
:::fold show each still with the weights and gain its manifest recorded
val cells =
readSweepArchive(archive).sortedBy { it.index }.joinToString("") { m ->
val still = archive.resolve("renders/${m.id}/still.png")
val weights = m.parents.joinToString("<br>") { "${it.key} ${it.weight.f2}" }
val gain = m.extrapolationGain?.let { "extrapolationGain ${it.f2}" } ?: "convex"
"<div style='display:inline-block;vertical-align:top;margin:4px;font:11px Iosevka,monospace;color:#aaa'>" +
"<img src='data:image/png;base64,${Base64.getEncoder().encodeToString(still.readBytes())}' " +
"width='260' style='border:1px solid #333;display:block'/><b>$gain</b><br>$weights</div>"
}
display("past the hull", "<div style='width:820px'>$cells</div>")
:::endfold
}

Why the weights are Dirichlet and not just normalised uniforms
Drawing n weights by normalising n uniforms — the obvious thing — concentrates hard on the
centre of the simplex as n grows. Every blend of five keys is then nearly their average, and a
night of it is a night of mush with no way to turn the dial. A symmetric Dirichlet is the
distribution that is genuinely uniform over the simplex at α = 1, and whose corner bias is a
parameter rather than an accident.
concentration is that α. KeyframeSweepTest pins what it buys, over five participants, by
measuring the mean largest weight: 0.81 at α = 0.1 (a blend is mostly one key), 0.46 at
α = 1 — which has a closed form, \( \tfrac{1}{n}\sum_i 1/i \) — and 0.23 at α = 50 (every blend is
the average). Start at 0.6 if the point is variety you can still attribute to something.
What a night survives
The sweeper assumes an unattended run can be interrupted or go wrong at any point.
- A resume does not change the draw. Each render's randomness comes from
seedand its index, nothing accumulating, so skipping four thousand finished renders leaves the unfinished ones drawing exactly what they would have drawn. A directory left"running"by a dead process is redone rather than skipped: its callback got some way through writing files nobody can vouch for. - A throwing callback costs one render. It is recorded with its first error line, its directory is
kept for forensics, and the run continues — until
maxConsecutiveFailuresin a row says the night is broken rather than the render unlucky, which is what stops a broken sweep writing ten thousand empty directories. - The disk floor stops the sweep before the disk does.
minFreeBytesis checked per render against the archive's own filesystem and defaults to 8 GB, generously, because an overnight sweep of GPU stills is a rate of consumption: what goes wrong is never the archive being large, it is the machine having nothing left at 4am.sweep.jsonthen says"stoppedBecause": "disk", which the render count alone would not. - Cancellation is clean. The stop key marks the in-flight render
cancelled, restores the scope to what it was before the run, and rethrows. maxDurationMsis the "stop at 7am" knob, and the callback itself is never interrupted: it runs to completion, one render at a time. There is aperRenderTimeout, off by default; switching it on accepts a partial render as the price of a bounded one.
Stills and clips
The sweeper is renderer-free; it blends, records, and calls your callback. But most callbacks do the
same two things, and those two have traps in them, so they live in
keyframeSweepCapture.kt once rather than in every
sweep.
sweep.run { render ->
renderer.awaitFrames(6)
render.captureStill(renderer, bundle.postStack.output(), settleFrames = 0)
render.captureClip(renderer, bundle.postStack.output(), frames = 24, fps = 12, settleFrames = 0) { _, t ->
// Where the callback moves whatever should move — a camera, a phase, a simulation step.
// Called before the readback, so frame i shows the state this established.
orbitAround(render.key["camera.at"] as Vector3f, (t - 0.5) * 0.5)
renderer.awaitFrames(2)
}
}
awaitFrames is the unit to wait in. delay(16) waits on a wall clock that has nothing to do with
whether the GPU drew anything: a uniform written this instant is visible only once the frame that read
it has been submitted, so "wait two frames" is the correct instruction and "sleep 32 ms" is a guess
that goes wrong exactly when the machine is busy — which, on the night of a long sweep, is always.
Neither helper will invent a file. A readback that comes back empty and a missing ffmpeg are both
recorded as an artifact with a null path and a stated reason, and the frame PNGs are left on disk, so
muxSweepArchiveClips can mux the whole archive the next morning without re-rendering the night.
One renderer and one target for the whole run
Both helpers assume the pattern the RectSL and StackSL gallery harnesses established: build the
RenderToTexture once, outside the sweep, and change only the uniforms. Building a target per
render is what makes an overnight sweep run out of device memory at 3am.
Reading a night back
The archive is designed to be read by something that was not there when it was written — which in
practice means a second process, often not Kotlin. index.jsonl is one JSON object per line, so the
Python end of an offline categorising pass is three lines:
import json, pathlib
rows = [json.loads(l) for l in (pathlib.Path(archive) / "index.jsonl").read_text().splitlines() if l.strip()]
still = lambda row: pathlib.Path(archive) / "renders" / row["id"] / "still.png"
Every field a categoriser needs to attribute a result is in that row: parents with weights,
anchor, extrapolationPole and extrapolationGain, discreteLanes, the filled lanes per parent,
durationMs, and artifacts with a size per file. Score the stills however you like, join the scores
back to the weights, and the question "which region of this space is worth another night" has an
answer.
From inside Field, sweepArchiveSummary(archive) is the one-line version for the morning: how many
renders, how they ended, how many left the hull, the widest single weight, the median render time, how
much of the pool got used, and any lane that was dropped or back-filled.
A loadable version
Open keyframe_autosweep from Starting points on the welcome screen, or open it here. It has four boxes: a renderer, the rig, the sweep and the morning box. Replace the rig with your own scene to start a real sweep against it. Its entry in Agent transcripts lists what each box does.
Agents: the tests behind this page
Two suites stand behind this page. afield.animation.keyframes.KeyframeSweepTest is CPU-only
and pins the provenance contract, the resume, the lane policies, the failure isolation, the disk floor and the sampling
distribution; tools/run_field_tests.py --id keyframe.sweep.smoke runs a real six-render sweep on a
GPU and pins that the picture actually follows the blend. Which claim belongs to which, and the gap
between them, is in dev_docs/KEYFRAME_SWEEP_AGREEMENT.md.