Skip to content

keyframer.kt

src/jvmMain/kotlin/afield/animation/keyframes/keyframer.kt · 2005 lines · cited by Keyframes: capturing, persisting and replaying live state

package and imports — 130 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.animation.keyframes

import afield.data.collections.r
import afield.data.collections.times
import afield.data.graph.main
import afield.data.graph.rules
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.properties.saved
import afield.document.workspace.files
import afield.geometry.mesh.repaired
import afield.graphics.color.rgba
import afield.graphics.interaction.pointer
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.rangeTo
import afield.graphics.shader.callable
import afield.graphics.shader.compute
import afield.graphics.shader.mesh
import afield.io.osc.main
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.control.main
import afield.math.functions.behaviour
import afield.math.linalg.dualquat
import afield.math.linalg.i
import afield.math.linalg.quat
import afield.math.linalg.remAssign
import afield.math.linalg.rgba
import afield.math.linalg.times
import afield.math.linalg.vec
import afield.math.statistics.main
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.runtime.execution.aside
import afield.runtime.execution.end
import afield.runtime.execution.go
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.simulation.automata.history
import afield.simulation.automata.main
import afield.study.source.sourceBlock
import afield.study.ui.numbers

import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.document.properties.clear
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.mesh.contains
import afield.graphics.render.label
import afield.graphics.shader.stackAutoOwnerOf
import afield.graphics.shader.stackAutoShaderSource
import afield.graphics.shader.stackAutoShaderSources
import afield.lang.stacksl.StackAutoNumbers
import afield.math.autodiff.max
import afield.math.autodiff.min
import afield.math.linalg.DualQuat
import afield.math.linalg.Quat
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.d
import afield.math.linalg.f
import afield.math.linalg.invoke
import afield.math.linalg.isNaN
import afield.math.linalg.set
import afield.math.linalg.x
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.study.source.SourceBackedBlock
import afield.study.source.sourceBackedBlockBox
import afield.ui.output.ControlGroupBuilder
import afield.ui.controls.ControlLaw
import afield.ui.output.ControlValueHandle
import afield.ui.visualization.ToHTML
import afield.ui.controls.defaultSignificantDigits
import afield.graphics.vulkan.util.Camera
import afield.graphics.vulkan.util.CameraPose
import afield.graphics.vulkan.util.applyPose
import afield.graphics.vulkan.util.pose
import afield.ui.visualization.h
import afield.app.diagnostics.logging.lprintln
import afield.graphics.vulkan.buffers.Structure
import afield.graphics.render.ComputeModulePointer
import afield.graphics.render.MeshModulePointer
import afield.graphics.render.ModulePointer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import afield.math.linalg.Quaternionf
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector2fc
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector3fc
import afield.math.linalg.Vector4f
import afield.math.linalg.Vector4fc
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import afield.math.linalg.DualQuaternionf
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlin.math.roundToInt
import afield.ui.visualization.facts
import afield.ui.visualization.Table
import afield.ui.output.FieldMarkdown
import afield.ui.visualization.CellRole

Snapshot, interpolate and persist the live state of a running sketch. A KeyframeScope is a set of named targets — typed Kotlin locals, keyframe-aware controlGroup controls, contributors such as a score object, uniform Structure bindings, the uniform structures of a ModulePointer / ComputeModulePointer / MeshModulePointer, a stackAuto shader-source slot via KeyframeScope.stackAuto, or a Camera pose via KeyframeScope.camera. capture() reads them all into a KeyframeScope.Key; invoking that Key writes them all back.

KeyframeScope.interpolate blends two keys per value type: numbers and vectors lerp, quaternions slerp, dual quaternions and CameraPose sclerp, and strings / StackSLString / SourceBackedBlock step to the right-hand value at t >= 1. remap can re-time an individual key's t. The two code types can do better than stepping when the two sources differ only in numbers — see KeyframeScope.codeBlend and keyframeCodeBlend.kt.

Persistence goes through the KValue serial union, so only the types KValue.fromAny recognises survive a save — numbers (a Long exactly), Booleans, vec2/3/4, quaternion, dual quaternion, camera pose, string, StackSL source and source-backed block; anything else is left out and reported on error,keyframer. Every saveKey also writes a numbered copy into a <file>.versions directory with a manifest, which KeyframeScope.keyVersions lists and KeyframeScope.loadLatestKey follows. KeyframeScope.missingKey supplies defaults for keys absent from an older file, optionally per schema version.

Keys render themselves as HTML for box output (KeyDiff, diffHtml), and copyUpdateCodeToClipboard emits pasteable Kotlin — that one goes through the AWT clipboard, and on a headless JVM it logs and just returns the string.

enum class NumKind

Which Kotlin numeric type a saved number came from, so that KValue.toAny can hand back a Byte/Short/Int/Long/Float/Double rather than always a Double — a uniform bound to an Int has to come back as an Int.

Every number is stored on the wire as a Double regardless of kind, so a Long beyond 2^53 does not survive a save/load round trip intact.

@Serializable
enum class NumKind { BYTE, SHORT, INT, LONG, FLOAT, DOUBLE }

How many code-blend plans one KeyframeScope keeps before dropping the lot.

A scrub between two fixed keys needs one entry per code slot and never grows. The limit is there for the other case — code that is being edited while it is being blended — where every frame is a new pair of sources and caching them would be a leak rather than a saving.

private const val CODE_BLEND_CACHE_LIMIT = 64

sealed interface KValue

The closed set of value types a KeyframeScope.Key can persist: numbers, vec2/3/4, quaternion, dual quaternion, camera pose, string, StackSL source and source-backed block. A key in memory holds plain Kotlin values; this is the serial union they are converted to on the way to disk and back.

The set is closed: fromAny returns null for anything else (a null, a list), and saveKey then reports each such entry on the error,keyframer log channel rather than writing it. Long and Boolean are stored exactly.

@Serializable
sealed interface KValue {

data class Num(val kind: NumKind, val value: Double) : KValue

Any Kotlin number, kept as a Double with the NumKind needed to restore its original type.

    @Serializable @SerialName("num")
    data class Num(val kind: NumKind, val value: Double) : KValue

data class LongValue(val value: Long) : KValue

A Long, exactly: a Double would lose it past 2^53.

    @Serializable @SerialName("long")
    data class LongValue(val value: Long) : KValue

data class Bool(val value: Boolean) : KValue

A Boolean.

    @Serializable @SerialName("bool")
    data class Bool(val value: Boolean) : KValue

data class Vec2(val x: Float, val y: Float) : KValue

A Vector2f, component by component.

    @Serializable @SerialName("vec2")
    data class Vec2(val x: Float, val y: Float) : KValue

data class Vec3(val x: Float, val y: Float, val z: Float) : KValue

A Vector3f, component by component.

    @Serializable @SerialName("vec3")
    data class Vec3(val x: Float, val y: Float, val z: Float) : KValue

data class Vec4(val x: Float, val y: Float, val z: Float, val w: Float) : KValue

A Vector4f, component by component — the usual RGBA or homogeneous-point uniform.

    @Serializable @SerialName("vec4")
    data class Vec4(val x: Float, val y: Float, val z: Float, val w: Float) : KValue

data class Quat(val x: Float, val y: Float, val z: Float, val w: Float) : KValue

A Quaternionf as raw x, y, z, w; stored as-is, and not re-normalised on load.

    @Serializable @SerialName("quat")
    data class Quat(val x: Float, val y: Float, val z: Float, val w: Float) : KValue

data class DualQuat

A DualQuaternionf — a rigid transform (rotation plus translation) as its real and dual quaternion halves. Unlike Quat this is normalised on the way back in, so a slightly drifted saved value is repaired on load.

    @Serializable @SerialName("dualquat")
    data class DualQuat(
        val realX: Float,
        val realY: Float,
        val realZ: Float,
        val realW: Float,
        val dualX: Float,
        val dualY: Float,
        val dualZ: Float,
        val dualW: Float
    ) : KValue

data class CameraPoseValue

A CameraPose: the camera's rigid transform as a dual quaternion, plus the focus distance that says how far in front of it the thing it is looking at sits. Also normalised on load.

    @Serializable @SerialName("cameraPose")
    data class CameraPoseValue(
        val realX: Float,
        val realY: Float,
        val realZ: Float,
        val realW: Float,
        val dualX: Float,
        val dualY: Float,
        val dualZ: Float,
        val dualW: Float,
        val focusDistance: Float
    ) : KValue

data class StackSL(val value: String) : KValue

StackSL source text, tagged as code rather than as a string so that it reloads as a StackSLString and interpolation steps it discretely instead of trying to blend it.

    @Serializable @SerialName("stacksl")
    data class StackSL(val value: String) : KValue

data class Str(val value: String) : KValue

A plain String.

    @Serializable @SerialName("string")
    data class Str(val value: String) : KValue

data class SourceBlock(val boxId: String, val source: String) : KValue

A SourceBackedBlock reduced to the box it came from and its Kotlin source — the live lambda is not persisted. On load the block re-compiles its source in that box on first invocation, so a key restored into a document where the box is gone throws when the value is finally called.

    @Serializable @SerialName("sourceBlock")
    data class SourceBlock(val boxId: String, val source: String) : KValue

fun toAny(): Any

Rebuild the live Kotlin value this entry stands for — the load half of the round trip. A DualQuat and a CameraPoseValue are re-normalised on the way out, because a rigid transform that has drifted is a broken transform; a plain Quat is not (see its own note — it may be standing in for something that is not a rotation). Numbers regain their NumKind type, and a SourceBlock becomes a SourceBackedBlock that has not compiled anything yet.

    fun toAny(): Any = when (this) {
        is LongValue -> value
        is Bool -> value
        is Num -> when (kind) {
            NumKind.BYTE -> value.toInt().toByte()
            NumKind.SHORT -> value.toInt().toShort()
            NumKind.INT -> value.toInt()
            NumKind.LONG -> value.toLong()
            NumKind.FLOAT -> value.toFloat()
            NumKind.DOUBLE -> value
        }
        is Vec2 -> Vector2f(x, y)
        is Vec3 -> Vector3f(x, y, z)
        is Vec4 -> Vector4f(x, y, z, w)
        is Quat -> Quaternionf(x, y, z, w)
        is DualQuat -> DualQuaternionf(
            Quaternionf(realX, realY, realZ, realW),
            Quaternionf(dualX, dualY, dualZ, dualW)
        ).normalize()
        is CameraPoseValue -> CameraPose(
            DualQuaternionf(
                Quaternionf(realX, realY, realZ, realW),
                Quaternionf(dualX, dualY, dualZ, dualW)
            ).normalize(),
            focusDistance
        )
        is StackSL -> StackSLString(value)
        is Str -> value
        is SourceBlock -> SourceBackedBlock<Any?>(boxId, source)
    }

companion object

    companion object {

fun fromAny(v: Any?): KValue?

The save half of the round trip: the KValue for a live value, or null for null and for any type outside the union — which is exactly how an unsupported value comes to be dropped from a saved key without complaint.

A Number that is none of the six primitive kinds is stored as a DOUBLE.

body — 44 lines, from line 316
        fun fromAny(v: Any?): KValue? = when (v) {
            null -> null
            is Byte -> Num(NumKind.BYTE, v.toDouble())
            is Short -> Num(NumKind.SHORT, v.toDouble())
            is Int -> Num(NumKind.INT, v.toDouble())
            is Long -> LongValue(v)
            is Boolean -> Bool(v)
            is Float -> Num(NumKind.FLOAT, v.toDouble())
            is Double -> Num(NumKind.DOUBLE, v)
            is Number -> Num(NumKind.DOUBLE, v.toDouble())

            is Vector2f -> Vec2(v.x, v.y)
            is Vector3f -> Vec3(v.x, v.y, v.z)
            is Vector4f -> Vec4(v.x, v.y, v.z, v.w)
            is Quaternionf -> Quat(v.x, v.y, v.z, v.w)
            is DualQuaternionf -> DualQuat(
                v.real.x,
                v.real.y,
                v.real.z,
                v.real.w,
                v.dual.x,
                v.dual.y,
                v.dual.z,
                v.dual.w
            )
            is CameraPose -> CameraPoseValue(
                v.transform.real.x,
                v.transform.real.y,
                v.transform.real.z,
                v.transform.real.w,
                v.transform.dual.x,
                v.transform.dual.y,
                v.transform.dual.z,
                v.transform.dual.w,
                v.focusDistance
            )
            is StackSLString -> StackSL(v.value)
            is String -> Str(v)
            is SourceBackedBlock<*> -> SourceBlock(v.boxId, v.source)

            else -> null
        }
    }
}

data class KeyDTO

What a saved key looks like on disk: a schema version and a name → KValue map, pretty-printed as JSON with type as the discriminator.

You rarely build one by hand — KeyframeScope.saveKey / loadKey do it — but this is the shape to read if you are diffing key files in a text editor or writing them from another tool.

@Serializable
data class KeyDTO(
    /**
     * The owning scope's `schemaVersion` at the moment of saving. Nothing validates it on load: a
     * mismatch is not an error, it only selects which [KeyframeScope.missingKey] defaults apply.
     */
    val schemaVersion: Int = 0,
    /** The key's entries by name. Values that [KValue.fromAny] could not represent are already gone. */
    val values: Map<String, KValue>
)

data class KeyVersionInfo

One row of a key file's version history, as recorded in the <file>.versions/manifest.json that every saveKey appends to. KeyframeScope.keyVersions hands these back, sorted by index.

A row can also be rebuilt from a numbered file the manifest does not describe — see KeyframeScope.keyVersions. Such a row has the file's own modification time for its timestamp and a note saying so, because the note is precisely what only the manifest was holding.

@Serializable
data class KeyVersionInfo(
    /**
     * Monotonic version number, starting at 0 and simply "highest so far plus one" — a save counter,
     * not a schema version and not related to [KeyDTO.schemaVersion].
     */
    val index: Int,
    /**
     * When the version was written, as an ISO-8601 `Instant.now()` string in UTC — or, for a row
     * rebuilt from the file alone, that file's last-modified time.
     */
    val timestamp: String,
    /**
     * Whatever note was passed to `saveKey`, for telling two snapshots apart later. A row rebuilt
     * from the file alone says `"(recovered from disk; the manifest row for this version was
     * lost)"` here instead, since the real note lived only in the manifest that went missing.
     */
    val note: String? = null,
    /** File name inside the `.versions` directory, `"%06d.json"` of [index]. */
    val file: String
)

@Serializable
private data class KeyVersionsDTO(
    val versions: List<KeyVersionInfo> = emptyList()
)

data class StackSLString(val value: String)

A string that a KeyframeScope should treat as code rather than as text: StackSL source.

The wrapper is what earns it code semantics — KeyframeScope.interpolate steps it to the right-hand value at t >= 1 instead of blending, it persists as the stacksl KValue rather than a plain string, and assigning a bare String over one keeps the wrapper. Equality is data-class equality on the source text, and toString gives the source back unchanged.

data class StackSLString(val value: String) {

override fun toString(): String = value

The StackSL source itself, so a StackSLString prints and concatenates like the string it wraps.

fun stackSLString(value: String) = StackSLString(value)

StackSLString as a function, which is what Key.updateCode emits so its output can be pasted back.

fun interface KeyframeContributor

A domain object that contributes its state to an existing KeyframeScope.

This is registration, not a second serialization scheme. The object flattens itself into the same ordinary numeric/vector/code lanes as shader structures and locals, so capture, interpolation, persistence and migration defaults remain one system. prefix lets several objects of the same kind coexist; the contributor defines the names below that prefix.

fun registerKeyframes(scope: KeyframeScope, prefix: String)

class KeyframeScope(val schemaVersion: Int = 0)

A named set of live things worth snapshotting — uniform Structure bindings, a module's live uniforms, a stack.auto shader slot, a Camera — plus the machinery to capture, blend, save and reload them. This is the object you keep around for the lifetime of a sketch.

Register targets with target, its typed local variants, include, stackAuto or camera; take a snapshot with capture, blend two snapshots with interpolate, and write one back to its targets by invoking it. A scope holds no values of its own: a Key does, and a Key is a plain map, so it outlives the frame it came from.

A key name may be qualified with @: everything after the last @ is the property name used on the target, so "glow@radius" and "blur@radius" can both drive a radius uniform on different structures. schemaVersion is stamped into every file this scope saves and chooses which missingKey defaults a load applies; nothing ever rejects a file for having a different one.

class KeyframeScope(val schemaVersion: Int = 0) {
    private class Target(
        val resolve: () -> Structure?,
        val bind: ((Function0<*>) -> Unit)? = null,
        val set: ((Any?) -> Unit)? = null,
        val get: (() -> Any?)? = null
    )

val targets = mutableMapOf<String, Structure>()

The statically-bound targets, by key name, as registered by target(name, structure) — exposed so you can see and clear what a scope is watching.

Writing to it directly works but skips the bookkeeping target does: a name that also has a lazily-resolved target still resolves to that one, because the dynamic table is consulted first.

    private val dynamicTargets = mutableMapOf<String, Target>()
    private val globalMissingKeys = mutableMapOf<String, () -> Any?>()
    private val schemaMissingKeys = mutableMapOf<Int, MutableMap<String, () -> Any?>>()

val remap = mutableMapOf<String, (Double) -> (Double)>()

Per-key re-timing for interpolate: remap["gain"] = { t -> t * t * (3 - 2 * t) } eases that one value on a smoothstep while everything else stays linear.

The function is handed the caller's t and its result is used in place of it for that key alone. Nothing clamps it, so returning outside 0..1 extrapolates numbers and vectors — and reaching >= 1 early is precisely how you make a code value switch before the end of a transition.

var codeBlend: CodeBlendOptions

What interpolate should do with a slot whose two sides are code — a StackSLString or a SourceBackedBlock.

The default is CodeBlendMode.STEP, which is the behaviour this class has always had: the right-hand source once t >= 1, the left one before that. Setting it to CodeBlendMode.STATIC or CodeBlendMode.RUNTIME turns on the structural blend described in keyframeCodeBlend.kt — the two sources are aligned, and where they differ only in numbers those numbers are interpolated. Two sources that differ structurally still step, whatever this says; codeBlendPlans is how to see which of the two happened.

Assigning a different CodeBlendOptions drops the cached plans, so a program built under the old options is not left running.

    var codeBlend: CodeBlendOptions = CodeBlendOptions()
        set(value) {
            if (field != value) clearCodeBlendCache()
            field = value
        }

Plans by the pair of sources they align, so that a CodeBlendMode.RUNTIME blend hands the slot the same program — and, for Kotlin, the same SourceBackedBlock — on every frame.

This is what makes the runtime path cheap rather than merely different: stackAutoShaderSource returns early when the source it is given equals the source already bound, and a SourceBackedBlock compiles its text once per instance. A fresh plan per call would defeat both. The cache is cleared when it passes CODE_BLEND_CACHE_LIMIT, which only happens when something is generating new code every frame — a case the runtime path cannot help anyway.

    private val codeBlendCache = mutableMapOf<Triple<CodeBlendLanguage, String, String>, CodeBlendPlan>()

The one SourceBackedBlock per runtime-blended Kotlin plan.

A SourceBackedBlock compiles its source on first invocation and remembers the lambda, so handing a target a fresh instance every frame would compile the same text through K2 every frame. The source is invariant in t by construction — that is what CodeBlendPlan.runtimeSource is for — so one instance per plan is all a blend ever needs.

    private val codeBlendBlocks = mutableMapOf<CodeBlendPlan, SourceBackedBlock<Any?>>()

    private fun clearCodeBlendCache() {
        codeBlendCache.clear()
        codeBlendBlocks.clear()
    }

    private fun codeBlendPlan(language: CodeBlendLanguage, left: String, right: String): CodeBlendPlan {
        val key = Triple(language, left, right)
        codeBlendCache[key]?.let { return it }
        if (codeBlendCache.size >= CODE_BLEND_CACHE_LIMIT) clearCodeBlendCache()
        val plan = when (language) {
            CodeBlendLanguage.STACKSL -> planStackSLCodeBlend(left, right, codeBlend)
            CodeBlendLanguage.KOTLIN -> planKotlinCodeBlend(left, right, codeBlend)
        }
        codeBlendCache[key] = plan
        return plan
    }

fun codeBlendPlans(a: Key, b: Key): Map<String, CodeBlendPlan>

The blend plan interpolate would use for every slot of a and b that holds code on both sides — the debugging view of a code blend, and what the keyframe browser shows.

Reading only: the plans are the cached ones, so asking costs one alignment per pair and nothing thereafter. Slots that are not code, or are code on one side only, are absent. A SourceBackedBlock pair from two different boxes is also absent, because there is no box to compile the blend in.

    fun codeBlendPlans(a: Key, b: Key): Map<String, CodeBlendPlan> {
        val out = linkedMapOf<String, CodeBlendPlan>()
        (a.values.keys + b.values.keys).sorted().forEach { name ->
            val va = a.values[name]
            val vb = b.values[name]
            when {
                va is StackSLString && vb is StackSLString ->
                    out[name] = codeBlendPlan(CodeBlendLanguage.STACKSL, va.value, vb.value)
                va is SourceBackedBlock<*> && vb is SourceBackedBlock<*> && va.boxId == vb.boxId ->
                    out[name] = codeBlendPlan(CodeBlendLanguage.KOTLIN, va.source, vb.source)
            }
        }
        return out
    }

inner class Key(val values: MutableMap<String, Any?>) : ToHTML

One snapshot: names to values, and the ability to put them all back. Get one from capture, interpolate, loadKey, or by constructing it from a map you wrote by hand (as the docs examples do).

A Key is a plain mutable map of live values — captured by reference, not deep-copied, so a SourceBackedBlock in a key is the same object the target holds. Invoking it (key()) writes every value back to its target. Nothing is written on any other operation: editing, diffing and saving all leave the running sketch exactly as it was.

    inner class Key(val values: MutableMap<String, Any?>) : ToHTML {

constructor(vararg values: Pair<String, Any?>) : this(linkedMapOf(*values))

Construct a snapshot from named values; repeated names keep their last supplied value.

operator fun invoke()

Write every value in this key back to its registered target — the moment a snapshot becomes the state of the sketch again.

Names whose target is no longer registered are skipped in silence, which is what makes an older key file safe to apply to a scope that has been rearranged since. A SourceBackedBlock is bound as the live callable itself; every other value is wrapped in a constant lambda. A null value is not skipped — it is written like any other, which for a stackAuto slot means replacing its shader source with the empty program.

        operator fun invoke() {
            values.forEach { (k, v) ->
                val target = targetFor(k)
                if (target != null) {
                    val binding = bindingForValue(v)
                    target.set?.invoke(v) ?: target.bind?.invoke(binding) ?: target.resolve()?.let { it.bindings[k] = binding }
                }
            }
        }

operator fun get(name: String): Any? = values[name]

key["gain"] — the snapshotted value, or null when this key has no such name.

operator fun set(name: String, value: Any?)

key["gain"] = 0.5 — edit the snapshot in memory. Nothing reaches the sketch until the key is invoked.

The incoming value is coerced to keep the kind already stored: assigning a plain String over a StackSLString or a SourceBackedBlock re-wraps it rather than degrading it to text, which is what lets a code value be edited as a string. No other coercion happens — assigning a Double over an Int really does change the type, and the target may not like it.

        operator fun set(name: String, value: Any?) {
            values[name] = coerceEditedValue(values[name], value)
        }

operator fun contains(name: String): Boolean = values.containsKey(name)

"gain" in key — whether this key carries that name at all (a name mapped to null still counts).

fun put(name: String, value: Any?): Key

set in chainable form, returning this same key (not a copy).

        fun put(name: String, value: Any?): Key {
            values[name] = coerceEditedValue(values[name], value)
            return this
        }

fun putAll(vararg entries: Pair<String, Any?>): Key

put for several names at once: key.putAll("gain" to 0.5, "pos" to vec(0, 0)).

        fun putAll(vararg entries: Pair<String, Any?>): Key {
            entries.forEach { (name, value) -> values[name] = coerceEditedValue(values[name], value) }
            return this
        }

fun remove(name: String): Any? = values.remove(name)

Drop a name from the snapshot, returning what was there. A removed name is left alone by invoke.

fun copy(): Key = Key(values.toMutableMap())

A separate key with a copy of the map — the usual way to keep a "before" snapshot while you edit. Shallow: both keys go on referencing the same value objects, so mutating a Vector3f in place is visible through both.

fun diff(other: Key, leftName: String = "left", rightName: String = "right"): KeyDiff

What changed between this key (the left side) and other (the right): names added, removed and changed, as a KeyDiff that renders itself for box output.

leftName and rightName are only column headings. Comparison is component-wise and exact for vectors, quaternions and poses — no tolerance — so two visually identical states an interpolation apart will still show as changed.

        fun diff(other: Key, leftName: String = "left", rightName: String = "right"): KeyDiff =
            KeyDiff(
                leftName = leftName,
                rightName = rightName,
                entries = diffEntries(values, other.values)
            )

fun diffHtml(other: Key, leftName: String = "left", rightName: String = "right"): String

diff straight to HTML, for display() or a box output.

        fun diffHtml(other: Key, leftName: String = "left", rightName: String = "right"): String =
            diff(other, leftName, rightName).toHTML()

fun diffText(other: Key, leftName: String = "left", rightName: String = "right"): String

diff as one line per change (+, -, ~), for a log or a console.

        fun diffText(other: Key, leftName: String = "left", rightName: String = "right"): String =
            diff(other, leftName, rightName).toString()

fun update(block: Key.() -> Unit): Key {

Edit the key in a block with this["name"] = …, and get the key back for chaining. In-place: this is the same key, edited, not a modified copy — and nothing is applied to the sketch.

        fun update(block: Key.() -> Unit): Key {
            block()
            return this
        }

fun applyKey(): Key

invoke in chainable form: write the key back and return it.

        fun applyKey(): Key {
            invoke()
            return this
        }

fun updateAndApply(block: Key.() -> Unit): Key {

update then invoke — the everyday "change these values and make it so" call.

        fun updateAndApply(block: Key.() -> Unit): Key {
            block()
            invoke()
            return this
        }

fun save(path: Path, note: String? = null): Key

Write this key to disk at path (creating parent directories) and append a numbered copy to its <path>.versions history with an optional note. Chainable. See KeyframeScope.saveKey for what survives the trip.

        fun save(path: Path, note: String? = null): Key {
            saveKey(path, this, note)
            return this
        }

fun updateCode(keyName: String = "key", apply: Boolean = true): String

The key as pasteable Kotlin: an update { … } or updateAndApply { … } block assigning every value, sorted by name — how a state you arrived at by fiddling becomes source you can commit.

Numbers keep their type (1.5f, 2L, .toByte()), vectors come out as vec(…), StackSL as stackSLString(…) and a source block as a raw string literal — except when its source contains a """, a trailing quote, or a $, any of which a raw string would mangle, and it falls back to a fully escaped literal instead. A value outside that set is emitted as null behind an "unsupported" block comment, so the code still compiles but nulls that value with no warning.

        fun updateCode(keyName: String = "key", apply: Boolean = true): String {
            val header = if (apply) "$keyName.updateAndApply {" else "$keyName.update {"
            return values.entries.sortedBy { it.key }.joinToString(
                separator = "\n",
                prefix = "$header\n",
                postfix = "\n}"
            ) { (name, value) ->
                "    this[${kotlinStringLiteral(name)}] = ${kotlinValueLiteral(value)}"
            }
        }

fun copyUpdateCodeToClipboard(keyName: String = "key", apply: Boolean = true): String

updateCode, put on the system clipboard and also returned, so a captured look is one call away from being pasted into a box.

That clipboard is the real AWT one: it will throw on a headless JVM, and it silently replaces whatever the user had copied.

        fun copyUpdateCodeToClipboard(keyName: String = "key", apply: Boolean = true): String {
            val code = updateCode(keyName, apply)
            // A headless JVM has no clipboard; hand the code back and say so rather than throw.
            runCatching { Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(code), null) }
                .onFailure {
                    afield.app.diagnostics.logging.lprintln(
                        "warning,keyframer", "no system clipboard (${it.javaClass.simpleName}); the update code is returned instead"
                    )
                }
            return code
        }

override fun toHTML(): String = keyframeKeyHtml(values)

The key as an HTML table of name/value rows, sorted by name — which is what a box shows when a Key is the last thing evaluated. Source blocks render with their source and a link to the owning box; a block whose box is not loaded says so in red.

    private fun coerceEditedValue(previous: Any?, next: Any?): Any? =
        when {
            previous is StackSLString && next is String -> StackSLString(next)
            previous is SourceBackedBlock<*> && next is String -> SourceBackedBlock<Any?>(previous.boxId, next)
            else -> next
        }

    private fun bindingForValue(value: Any?): Function0<*> =
        if (value is SourceBackedBlock<*>) value else ({ value })

Put a key's value back the way the property held it: a property that held a plain value gets the value again (structurename= v), and one that held a binding, or a live block, gets a binding. Capture and apply are then symmetric, with nothing left shadowed underneath.

    private fun writeKeyValue(structure: Structure?, property: String, value: Any?) {
        if (structure == null) return
        if (value != null && value !is SourceBackedBlock<*> && structure.bindingFor(property) == null) {
            structure[property] = value
        } else {
            structure.bind(property, bindingForValue(value))
        }
    }

Read one property off a structure the way everything else in the engine reads it — the binding if there is one, otherwise the plain value, which is the same precedence Structure.get and the buffer writer use. A SourceBackedBlock binding is handed back as the block itself rather than invoked, because a key stores the live callable.

Writing is symmetric: writeKeyValue puts a value-held property back as a value and a bound one back as a binding.

    private fun readProperty(structure: Structure?, property: String): Any? {
        val binding = structure?.bindingFor(property) ?: return structure?.valueFor(property)
        return if (binding is SourceBackedBlock<*>) binding else binding.invoke()
    }

    private fun targetPropertyName(keyName: String): String =
        keyName.substringAfterLast('@')

    private fun targetFor(name: String): Target? =
        dynamicTargets[name] ?: targets[name]?.let { structure ->
            val propertyName = targetPropertyName(name)
            Target(
                resolve = { structure },
                bind = { value -> structure.bind(propertyName, value) },
                set = { value -> writeKeyValue(structure, propertyName, value) },
                get = { readProperty(structure, propertyName) }
            )
        }

fun target(name: String, structure: Structure)

Register a key name against a uniform structure held for good — the simplest target, for a Structure you built once and will keep.

The property bound on the structure is the part of name after the last @, so "glow@radius" drives radius. Registering a name twice replaces the earlier target, whichever kind it was.

    fun target(name: String, structure: Structure) {
        targets[name] = structure
        dynamicTargets.remove(name)
    }

fun target(name: String, structure: () -> Structure?) {

Register a key name against a structure that is looked up again on every read and write — the right choice for anything a shader reload can replace underneath you.

A lambda that returns null simply makes the key unreadable for now: capture records null for it and interpolate skips it, rather than failing.

    fun target(name: String, structure: () -> Structure?) {
        val propertyName = targetPropertyName(name)
        dynamicTargets[name] = Target(
            resolve = structure,
            bind = { value -> structure()?.bind(propertyName, value) },
            set = { value -> writeKeyValue(structure(), propertyName, value) },
            get = { readProperty(structure(), propertyName) }
        )
        targets.remove(name)
    }

fun target(name: String, get: () -> Any?, set: (Any?) -> Unit) {

Register an arbitrary live value through explicit get and set functions. This is the escape hatch for score objects, simulation state and other things that are not shader Structures: capture calls get, and invoking a Key hands its stored value to set.

The value still has ordinary keyframer semantics. interpolate chooses its behaviour from the runtime type returned by get, and persistence only keeps types recognised by KValue.fromAny. The setter therefore receives Any?; it should validate or safely ignore a value of the wrong type. Registering the same name again replaces any earlier target.

    fun target(name: String, get: () -> Any?, set: (Any?) -> Unit) {
        dynamicTargets[name] = Target(
            resolve = { null },
            set = set,
            get = get
        )
        targets.remove(name)
    }

fun floatTarget(name: String, get: () -> Float, set: (Float) -> Unit) {

Register a float local without exposing an Any? setter at the call site.

    fun floatTarget(name: String, get: () -> Float, set: (Float) -> Unit) {
        target(name, get = get, set = { value -> set(value.requireKeyframeNumber(name).toFloat()) })
    }

fun doubleTarget(name: String, get: () -> Double, set: (Double) -> Unit) {

Register a double local without exposing an Any? setter at the call site.

    fun doubleTarget(name: String, get: () -> Double, set: (Double) -> Unit) {
        target(name, get = get, set = { value -> set(value.requireKeyframeNumber(name).toDouble()) })
    }

fun booleanTarget(name: String, get: () -> Boolean, set: (Boolean) -> Unit) {

Register a Boolean local as an interpolable float lane. Applying crosses to true at 0.5; this is useful when a fade eventually switches a CPU-side path as well as a shader.

    fun booleanTarget(name: String, get: () -> Boolean, set: (Boolean) -> Unit) {
        target(
            name,
            get = { if (get()) 1f else 0f },
            set = { value -> set(value.requireKeyframeNumber(name).toFloat() >= 0.5f) }
        )
    }

fun intTarget

Register an integer local as a float lane, rounding and clamping when a key is applied.

    fun intTarget(
        name: String,
        get: () -> Int,
        range: IntRange = Int.MIN_VALUE..Int.MAX_VALUE,
        set: (Int) -> Unit
    ) {
        target(
            name,
            get = { get().toFloat() },
            set = { value -> set(value.requireKeyframeNumber(name).toFloat().roundToInt().coerceIn(range)) }
        )
    }

fun vector2Target(name: String, get: () -> Vector2fc, set: (Vector2f) -> Unit) {

Register a vec2 local, copying at both boundaries so a captured key cannot alias it.

    fun vector2Target(name: String, get: () -> Vector2fc, set: (Vector2f) -> Unit) {
        target(
            name,
            get = { Vector2f(get()) },
            set = { value -> set(Vector2f(value.requireKeyframeType<Vector2fc>(name))) }
        )
    }

fun vector3Target(name: String, get: () -> Vector3fc, set: (Vector3f) -> Unit) {

Register a vec3 local, copying at both boundaries so a captured key cannot alias it.

    fun vector3Target(name: String, get: () -> Vector3fc, set: (Vector3f) -> Unit) {
        target(
            name,
            get = { Vector3f(get()) },
            set = { value -> set(Vector3f(value.requireKeyframeType<Vector3fc>(name))) }
        )
    }

fun vector4Target(name: String, get: () -> Vector4fc, set: (Vector4f) -> Unit) {

Register a vec4 local, copying at both boundaries so a captured key cannot alias it.

    fun vector4Target(name: String, get: () -> Vector4fc, set: (Vector4f) -> Unit) {
        target(
            name,
            get = { Vector4f(get()) },
            set = { value -> set(Vector4f(value.requireKeyframeType<Vector4fc>(name))) }
        )
    }

fun stringTarget(name: String, get: () -> String, set: (String) -> Unit) {

Register a string local. Strings retain the keyframer's endpoint-step interpolation.

    fun stringTarget(name: String, get: () -> String, set: (String) -> Unit) {
        target(name, get = get, set = { value -> set(value.requireKeyframeType<String>(name)) })
    }

fun include(contributor: KeyframeContributor, prefix: String = ""): KeyframeScope

Add every lane supplied by contributor to this scope and return the scope for chaining.

    fun include(contributor: KeyframeContributor, prefix: String = ""): KeyframeScope = apply {
        contributor.registerKeyframes(this, prefix.trim('.'))
    }

    private fun Any?.requireKeyframeNumber(name: String): Number =
        this as? Number
            ?: error("keyframe '$name' expected a number, got ${this?.let { it::class.simpleName } ?: "null"}")

    private inline fun <reified T : Any> Any?.requireKeyframeType(name: String): T =
        this as? T
            ?: error("keyframe '$name' expected ${T::class.simpleName}, got ${this?.let { it::class.simpleName } ?: "null"}")

fun target(module: ModulePointer, name: String)

Register a key against one of a graphics module's live uniform structures, resolved through the pointer each time so it survives a shader reload.

name is the key name, and after the last @ it is block.member: the uniform block to look up in module.current.liveUniformStructures and the member inside it. A slot with no . uses the same name for both.

    fun target(module: ModulePointer, name: String) =
        uniformTarget(name) { module.current.liveUniformStructures[it] }

fun target(module: ComputeModulePointer, name: String)

target for a compute module's live uniform structures.

    fun target(module: ComputeModulePointer, name: String) =
        uniformTarget(name) { module.current.liveUniformStructures[it] }

fun target(module: MeshModulePointer, name: String)

target for a mesh-shader module's live uniform structures.

    fun target(module: MeshModulePointer, name: String) =
        uniformTarget(name) { module.current.liveUniformStructures[it] }

A key on one member of a module's uniform block. The part of name after the last @ is block.member, so "tint@params.color" keys color in the block params; a slot with no . names a block and its member alike, as before.

    private fun uniformTarget(name: String, structureFor: (block: String) -> Structure?) {
        val slot = targetPropertyName(name)
        val block = slot.substringBefore('.')
        val member = slot.substringAfter('.', slot)
        dynamicTargets[name] = Target(
            resolve = { structureFor(block) },
            bind = { value -> structureFor(block)?.bind(member, value) },
            set = { value -> writeKeyValue(structureFor(block), member, value) },
            get = { readProperty(structureFor(block), member) }
        )
        targets.remove(name)
    }

fun stackAuto(module: ModulePointer, name: String, keyName: String = name)

Register a key against a stack.auto.<name> shader-source slot, so that the StackSL a module runs is part of the snapshot: capture reads it as a StackSLString and applying a key recompiles the slot. This is what lets a keyframe morph a patch's code as well as its numbers.

keyName defaults to name, and as elsewhere the slot addressed is the part of name after the last @. Applying a value writes the shader source through stackAutoShaderSource, which swallows a compile failure: the error is reported on the owning box as a shader-stage error, the old source stays in place, and the key that failed to apply says nothing about it.

    fun stackAuto(module: ModulePointer, name: String, keyName: String = name) {
        val stackAutoName = targetPropertyName(name)
        dynamicTargets[keyName] = Target(
            resolve = { null },
            set = { value -> stackAutoShaderSource(module, stackAutoName, (value as? StackSLString)?.value ?: value?.toString().orEmpty()) },
            get = { StackSLString(stackAutoShaderSource(module, stackAutoName)) }
        )
        targets.remove(keyName)
    }

fun stackAutoNumbers(module: ModulePointer, slot: String, keyPrefix: String = slot)

Register each number typed in the StackSL of a stack.auto.<slot> slot as a key of its own — slot.k0, slot.k1, … numbered as the box's numbers card numbers them — so a key snapshots them and applying or blending keys turns them on the GPU with no recompile (StackAutoNumbers).

stackAuto keys the slot's text, and applying it recompiles; this keys its numbers, and does not. Use both and a key holds the program and where its numbers were turned to. The lanes are the program's numbers when this is called: call it again after the program's shape changes. Applying a key with lanes the program no longer has does nothing for them.

    fun stackAutoNumbers(module: ModulePointer, slot: String, keyPrefix: String = slot) = stackAutoNumbersOf(stackAutoOwnerOf(module), slot, keyPrefix)

fun stackAutoNumbers(module: ComputeModulePointer, slot: String, keyPrefix: String = slot)

stackAutoNumbers for a compute module.

    fun stackAutoNumbers(module: ComputeModulePointer, slot: String, keyPrefix: String = slot) = stackAutoNumbersOf(stackAutoOwnerOf(module), slot, keyPrefix)

fun stackAutoNumbers(module: MeshModulePointer, slot: String, keyPrefix: String = slot)

stackAutoNumbers for a mesh-shader module.

    fun stackAutoNumbers(module: MeshModulePointer, slot: String, keyPrefix: String = slot) = stackAutoNumbersOf(stackAutoOwnerOf(module), slot, keyPrefix)

    private fun stackAutoNumbersOf(box: afield.document.graph.Box?, slot: String, keyPrefix: String) {
        box ?: error("no box owns this module's stack.auto.$slot")
        // The slot's text as last assigned: its numbers are the same whether or not the reload has run yet.
        val source = box.stackAutoShaderSources[slot]?.takeIf { it.isNotBlank() } ?: StackAutoNumbers.slots(box)[slot]?.source
            ?: error("stack.auto.$slot has no program yet; assign one (module.$slot = …) before registering its numbers")
        val entrypoint = StackAutoNumbers.slots(box)[slot]?.entrypoint
        afield.lang.stacksl.study.StackSLStudyShader.knobs(source, entrypoint).forEach { k ->
            doubleTarget("$keyPrefix.k${k.knob}", { StackAutoNumbers.value(box, slot, k.wire) ?: k.value }) { StackAutoNumbers.set(box, slot, k.wire, it) }
        }
    }

fun stackAuto(module: ComputeModulePointer, name: String, keyName: String = name)

stackAuto for a compute module; the source is reloaded on the compute path.

    fun stackAuto(module: ComputeModulePointer, name: String, keyName: String = name) {
        val stackAutoName = targetPropertyName(name)
        dynamicTargets[keyName] = Target(
            resolve = { null },
            set = { value -> stackAutoShaderSource(module, stackAutoName, (value as? StackSLString)?.value ?: value?.toString().orEmpty()) },
            get = { StackSLString(stackAutoShaderSource(module, stackAutoName)) }
        )
        targets.remove(keyName)
    }

fun stackAuto(module: MeshModulePointer, name: String, keyName: String = name)

stackAuto for a mesh-shader module; the source is reloaded on the mesh path.

    fun stackAuto(module: MeshModulePointer, name: String, keyName: String = name) {
        val stackAutoName = targetPropertyName(name)
        dynamicTargets[keyName] = Target(
            resolve = { null },
            set = { value -> stackAutoShaderSource(module, stackAutoName, (value as? StackSLString)?.value ?: value?.toString().orEmpty()) },
            get = { StackSLString(stackAutoShaderSource(module, stackAutoName)) }
        )
        targets.remove(keyName)
    }

fun camera(camera: Camera, keyName: String = "camera")

Register a Camera as a keyable target, so a viewpoint can be snapshotted and morphed like any other value. Capture stores the camera's CameraPose — its rigid transform plus focus distance — and applying a key calls applyPose, so interpolate gives you a screw-motion (sclerp) camera move rather than a separate position and target lerp.

A raw DualQuaternionf value is accepted too and keeps the camera's current distance to target as the focus. Any other type is not applied, and says so on the error,keyframer log channel.

    fun camera(camera: Camera, keyName: String = "camera") {
        dynamicTargets[keyName] = Target(
            resolve = { null },
            set = { value ->
                when (value) {
                    is CameraPose -> camera.applyPose(value)
                    is DualQuaternionf -> camera.applyPose(CameraPose(value, camera.distanceToTarget()))
                    else -> afield.app.diagnostics.logging.lprintln(
                        "error,keyframer",
                        "camera key '$keyName' holds ${if (value == null) "null" else value::class.qualifiedName}; " +
                            "a camera target applies a CameraPose or a DualQuaternionf, so nothing was applied"
                    )
                }
            },
            get = { camera.pose() }
        )
        targets.remove(keyName)
    }

fun findByName(name : String) : ((Function0<*>) -> Unit)? {

The raw "bind a lambda to this target" function for a registered key name, or null if there is no such key — an escape hatch for driving a target continuously instead of through a Key.

It only ever finds structure targets: stackAuto and camera targets set values through a setter rather than a binding, so they have no bind function and this returns null for them even though the key exists.

    fun findByName(name : String) : ((Function0<*>) -> Unit)? {
        targetFor(name)?.bind?.let { return it }
        return null
    }

fun missingKey(name: String, value: Any?)

Supply the value a load should use for a key that the file does not contain — how a scope that has grown a new parameter still loads snapshots taken before it existed.

Applies to every file this scope loads, whatever its schema version. Only ever fills in absent names: a name present in the file wins, even if its value is null. Registering the same name twice replaces the earlier default, and the default lands in the loaded Key without being applied to anything — invoke the key for that.

    fun missingKey(name: String, value: Any?) {
        globalMissingKeys[name] = { value }
    }

fun missingKey(name: String, value: () -> Any?) {

missingKey with the default computed at load time rather than fixed now — use it when the sensible fallback is "whatever the target currently holds".

    fun missingKey(name: String, value: () -> Any?) {
        globalMissingKeys[name] = value
    }

fun missingKey(schemaVersion: Int, name: String, value: Any?)

A default that applies only when the loaded file's KeyDTO.schemaVersion equals schemaVersion — for the case where "the old files meant something different" rather than "the old files are missing this".

Version-specific defaults are applied after the version-agnostic ones, so for a matching file this wins over a plain missingKey for the same name.

    fun missingKey(schemaVersion: Int, name: String, value: Any?) {
        schemaMissingKeys.getOrPut(schemaVersion) { mutableMapOf() }[name] = { value }
    }

fun missingKey(schemaVersion: Int, name: String, value: () -> Any?) {

The schema-specific missingKey with a lambda default. Evaluated at most once per load, and only when the file actually lacks the name — a default whose name the file already carries is never called at all.

    fun missingKey(schemaVersion: Int, name: String, value: () -> Any?) {
        schemaMissingKeys.getOrPut(schemaVersion) { mutableMapOf() }[name] = value
    }

    private val delegatedControls = linkedMapOf<String, KeyframeControl<*>>()
    private val controlChangeListeners = mutableListOf<(String) -> Unit>()

internal fun declareControl(control: KeyframeControl<*>)

    internal fun declareControl(control: KeyframeControl<*>) {
        delegatedControls.remove(control.name)
        delegatedControls[control.name] = control
    }

internal fun declaredControls(): List<KeyframeControl<*>> = delegatedControls.values.toList()

internal fun fireControlChange(name: String)

    internal fun fireControlChange(name: String) {
        controlChangeListeners.toList().forEach { it(name) }
    }

fun onChange(listener: (name: String) -> Unit) {

Call listener with the key name whenever a control declared with by keys.slider(...) (and the other KeyframeControl kinds) changes from outside the code: a hand on the control, or a recalled or morphed key. Assigning the local in code does not call it, and neither does a rerun: the local already holds the panel's last value from its declaration, so the body that follows sees it.

    fun onChange(listener: (name: String) -> Unit) {
        controlChangeListeners += listener
    }

fun capture()

Read every registered target right now into a new Key — the snapshot call.

A structure property is read binding-first and then by value — the same precedence Structure.get and the buffer writer use — so structure["radius"] = 4f snapshots just as well as structure.bind("radius") { 4f }. Applying the key writes each back the way it was held: a value as a value, a binding as a binding.

Values are taken as they are, not copied, so a mutable Vector3f in a key is still the vector the structure is bound to; keep the key across frames only if you also stopped mutating that vector in place. A target that cannot currently be resolved contributes a null, which interpolate skips and saveKey drops.

    fun capture() =
        Key((targets.keys + dynamicTargets.keys).map { k -> k to targetFor(k)?.get?.invoke() }
            .associate { it }.toMutableMap())

fun interpolate(a: Key, b: Key, t: Double): Key

Blend two snapshots into a new Key at position t, per value type: numbers and vectors lerp, quaternions slerp, dual quaternions and camera poses sclerp, and strings step to b's value once t >= 1.

StackSLStrings and source blocks step too, unless codeBlend says otherwise: with a CodeBlendMode set they are aligned against each other and blended where they differ only in numbers, and they still step when they differ in anything else. With a target registered under CodeBlendOptions.blendKey, that lane is also filled with keyframeBlendVector so a runtime-blended shader can read t as a push constant.

t runs 0 at a to 1 at b but is not clamped, so it extrapolates cheerfully past either end; remap can re-time any single key first. Returns a fresh key and touches nothing live — invoke the result to apply it.

Entries that are not blendable are simply absent from the result: a name in only one of the two keys, a null on either side, or two values of different types. Numbers come back as Float only if both sides were Float, otherwise as Double — so interpolating two Int keyframes yields a Double, which an Int uniform will not accept.

body — 52 lines, from line 1183
    fun interpolate(a: Key, b: Key, t: Double): Key {
        fun lerp(x: Double, y: Double, tt: Double) = x + (y - x) * tt

        val out = mutableMapOf<String, Any?>()
        val allKeys = a.values.keys + b.values.keys

        for (key in allKeys) {
            val va = a.values[key]
            val vb = b.values[key]
            if (va == null || vb == null) continue

            val tEffective = remap[key]?.invoke(t) ?: t
            val tf = tEffective.toFloat()

            val interpolated: Any = when {
                va is Number && vb is Number -> {
                    val v = lerp(va.toDouble(), vb.toDouble(), tEffective)
                    // The lane keeps its own numeric kind, so an Int uniform still gets an Int.
                    when {
                        va is Int && vb is Int -> Math.round(v).toInt()
                        va is Long && vb is Long -> Math.round(v)
                        va is Double || vb is Double -> v
                        va is Float || vb is Float -> v.toFloat()
                        else -> v
                    }
                }

                va is Vector2f && vb is Vector2f -> Vector2f(va).lerp(vb, tf)
                va is Vector3f && vb is Vector3f -> Vector3f(va).lerp(vb, tf)
                va is Vector4f && vb is Vector4f -> Vector4f(va).lerp(vb, tf)
                va is Quaternionf && vb is Quaternionf -> Quaternionf(va).slerp(vb, tf)
                va is DualQuaternionf && vb is DualQuaternionf -> DualQuaternionf(va).sclerp(vb, tf)
                va is CameraPose && vb is CameraPose -> va.sclerp(vb, tf)
                va is StackSLString && vb is StackSLString -> blendStackSLValue(va, vb, tEffective)
                va is String && vb is String -> if (tEffective >= 1.0) vb else va
                va is SourceBackedBlock<*> && vb is SourceBackedBlock<*> -> blendBlockValue(va, vb, tEffective)

                else -> continue
            }

            out[key] = interpolated
        }

        // The blend parameter itself, for the push-constant convention: written last so it wins over
        // whatever lerping the loop above did to the vec4 the two keys captured.
        val blendLane = codeBlend.blendKey
        if (codeBlend.mode != CodeBlendMode.STEP && targetFor(blendLane) != null) {
            out[blendLane] = keyframeBlendVector(remap[blendLane]?.invoke(t) ?: t)
        }

        return Key(out)
    }

One StackSL slot's blended value: the plan's program under CodeBlendMode.STATIC or CodeBlendMode.RUNTIME, and the stepped endpoint otherwise.

The runtime program is wrapped in a fresh StackSLString each call and that costs nothing: stackAutoShaderSource compares the text it is handed against the text already bound and returns without recompiling when they match, and a runtime program's text does not depend on t.

    private fun blendStackSLValue(a: StackSLString, b: StackSLString, t: Double): Any {
        if (codeBlend.mode == CodeBlendMode.STEP) return if (t >= 1.0) b else a
        val plan = codeBlendPlan(CodeBlendLanguage.STACKSL, a.value, b.value)
        return when (plan.effectiveMode) {
            CodeBlendMode.STEP -> if (t >= 1.0) b else a
            CodeBlendMode.STATIC -> StackSLString(plan.bake(t))
            CodeBlendMode.RUNTIME -> StackSLString(plan.runtimeSource)
        }
    }

One SourceBackedBlock slot's blended value.

Two blocks from different boxes step rather than blend: the blended source has to be compiled somewhere, and neither box is more right than the other. A CodeBlendMode.RUNTIME blend publishes t through CodeBlendPlan.drive and hands back the plan's one block, so the generated source is compiled once and reads the new t on its next invocation.

    private fun blendBlockValue(a: SourceBackedBlock<*>, b: SourceBackedBlock<*>, t: Double): Any {
        if (codeBlend.mode == CodeBlendMode.STEP || a.boxId != b.boxId) return if (t >= 1.0) b else a
        val plan = codeBlendPlan(CodeBlendLanguage.KOTLIN, a.source, b.source)
        return when (plan.effectiveMode) {
            CodeBlendMode.STEP -> if (t >= 1.0) b else a
            CodeBlendMode.STATIC -> SourceBackedBlock<Any?>(a.boxId, plan.bake(t))
            CodeBlendMode.RUNTIME -> {
                plan.drive(t)
                codeBlendBlocks.getOrPut(plan) { SourceBackedBlock(a.boxId, plan.runtimeSource) }
            }
        }
    }

persistence

    private val json = Json {
        prettyPrint = true
        ignoreUnknownKeys = true
        classDiscriminator = "type"
        encodeDefaults = true
    }

    private fun Key.toDTO(): KeyDTO =
        KeyDTO(
            schemaVersion = schemaVersion,
            values.mapNotNull { (k, v) ->
                val kv = KValue.fromAny(v)
                if (kv == null) {
                    afield.app.diagnostics.logging.lprintln(
                        "error,keyframer",
                        "keyframe value '$k' was not saved: ${if (v == null) "null" else v::class.qualifiedName} is not a type a key can store"
                    )
                    return@mapNotNull null
                }
                k to kv
            }.toMap()
        )

    private fun keyFromDTO(dto: KeyDTO): Key {
        val values = mutableMapOf<String, Any?>()
        dto.values.forEach { (name, value) -> values[name] = value.toAny() }
        val defaults = globalMissingKeys.toMutableMap()
        schemaMissingKeys[dto.schemaVersion]?.let { defaults.putAll(it) }
        defaults.forEach { (name, defaultValue) ->
            if (!values.containsKey(name)) values[name] = defaultValue()
        }
        return Key(values)
    }

fun encodeKeyJson(key: Key): String = json.encodeToString(key.toDTO())

key as the same pretty JSON saveKey writes, without writing anything.

For the caller who wants the file and not the history: saveKey is always additive, so it leaves a <path>.versions directory with one numbered snapshot beside every file it writes. That is right for a key you keep pushing on and wrong for a write-once key — a sweep archive of ten thousand blends does not want ten thousand one-entry histories (KeyframeSweep writes its key.json through this). The text is interchangeable either way: Files.writeString(path, scope.encodeKeyJson(key)) produces a file loadKey reads and KeyframeCollection browses.

Leaves out exactly what saveKey leaves out — nulls and types KValue.fromAny does not recognise — and reports each on error,keyframer.

fun saveKey(path: Path, key: Key, note: String? = null)

Write key to path as pretty JSON, creating parent directories — and, every time, also append a numbered copy to a <path>.versions directory with a manifest row carrying note and a timestamp. Saving is therefore always additive history, never just an overwrite; nothing prunes it, so a hundred saves leave a hundred files.

"Additive" holds even when the manifest is missing, unreadable or behind: the next number comes from the numbered files themselves, the way nextSafeSaveBackup (saving.kt:1816) numbers a document backup. See keyVersions for what a damaged manifest costs, which is notes, not snapshots.

Only values KValue.fromAny recognises are written; each null or unsupported value is reported on error,keyframer. A SourceBackedBlock is written as its box id plus source text, not as a live lambda.

    fun saveKey(path: Path, key: Key, note: String? = null) {
        path.parent?.createDirectories()
        val text = json.encodeToString(key.toDTO())
        saveKeyVersion(path, text, note)
        Files.writeString(path, text)
    }

fun loadKey(path: Path, version: Int? = null): Key

Read a saved key back as a Key, filling in any missingKey defaults for names the file lacks. Reading only: nothing is applied to the sketch until you invoke the returned key.

With version null this reads path itself, i.e. the most recent save. With a version index it reads that entry of the .versions history instead, and throws IllegalArgumentException when no such version is listed. A file whose values have since left the scope loads fine — the extra names simply have nowhere to go on apply. Unknown JSON keys are ignored, but a malformed file throws.

    fun loadKey(path: Path, version: Int? = null): Key {
        val text = Files.readString(version?.let { keyVersionFile(path, it) } ?: path)
        val dto = json.decodeFromString<KeyDTO>(text)
        return keyFromDTO(dto)
    }

fun loadLatestKey(path: Path): Key

The highest-numbered entry of path's version history, falling back to path itself when there is no history at all.

In normal use that is the same content as loadKey(path), since every save writes both. It differs when the main file has been edited or replaced by hand behind the history's back — then this prefers what Field last wrote.

    fun loadLatestKey(path: Path): Key =
        keyVersions(path).lastOrNull()?.let { loadKey(path, it.index) } ?: loadKey(path)

fun loadKeyVersion(path: Path, version: Int): Key

loadKey(path, version) by another name, for when the version is not optional at the call site.

    fun loadKeyVersion(path: Path, version: Int): Key =
        loadKey(path, version)

fun keyVersions(path: Path): List<KeyVersionInfo> = readKeyVersions(path)

The save history of path, oldest first — the list to show a person choosing which snapshot to go back to.

The numbered files in <path>.versions are the history; manifest.json is a cache of the notes and timestamps a file name cannot carry. So this is the union of the two, and neither one alone can lose a version:

  • a manifest row whose file has since been deleted is still listed, because the row is the only surviving record that the version existed (loading it then throws, which is the right answer);
  • a numbered file the manifest does not mention is listed too, with the file's own modification time and a note saying it was recovered. That covers a manifest deleted by hand, one truncated mid-write, and the window inside saveKeyVersion between writing the file and writing the manifest.

Empty only when nothing has been saved. An unparseable manifest is reported on the error,keyframer log channel and recovered from — never read as "no history" without saying so, which is what used to make the next save renumber from 0 and overwrite 000000.json. Throws if the .versions directory exists but cannot be listed, rather than guess at a history it cannot see.

fun savedKeyNames(dir: Path): List<String>

The names of the keys saved directly in dir — each file saveKey wrote there, sorted, without the .versions histories beside them. Empty when dir does not exist yet. This is the list a preset strip shows; KeyframeCollection.load reads the same files with their contents.

    fun savedKeyNames(dir: Path): List<String> {
        if (!dir.isDirectory()) return emptyList()
        return dir.listDirectoryEntries()
            .filter { it.isRegularFile() && !it.name.startsWith(".") }
            .map { it.name }
            .sorted()
    }

fun interpolateAlong(keys: List<Key>, t: Double): Key

interpolate along a row of keys: they sit evenly along t in 0..1, in order, and between two neighbours the blend is theirs. t is clamped here, since past either end there is no neighbour to extrapolate towards. One key comes back as itself; none is an error.

body — 54 lines, from line 1420
    fun interpolateAlong(keys: List<Key>, t: Double): Key {
        require(keys.isNotEmpty()) { "interpolateAlong needs at least one key" }
        if (keys.size == 1) return keys[0]
        val s = t.coerceIn(0.0, 1.0) * (keys.size - 1)
        val i = s.toInt().coerceAtMost(keys.size - 2)
        return interpolate(keys[i], keys[i + 1], s - i)
    }

    private fun saveKeyVersion(path: Path, text: String, note: String?) {
        val dir = keyVersionsDir(path)
        dir.createDirectories()

        val manifestFile = keyVersionsManifest(path)
        val rows = parseKeyVersionManifest(manifestFile)
        // The write at the end of this function would replace an unreadable manifest, destroying
        // notes that nothing else records. Keep it for a person to pick over instead.
        if (rows == null) quarantineKeyVersionManifest(manifestFile)

        val known = reconcileKeyVersions(dir, rows ?: emptyList())
        var nextIndex = (known.maxOfOrNull { it.index } ?: -1) + 1
        // `known` already accounts for every numbered file in the directory, so this loop can only
        // fire when something outside this call wrote one — a second process saving the same key.
        // Step past it and say so; the one thing never to do is overwrite it.
        while (dir.resolve(keyVersionFileName(nextIndex)).exists()) {
            lprintln(
                "error,keyframer",
                "key version ${keyVersionFileName(nextIndex)} of $path appeared underneath this save; " +
                    "using ${nextIndex + 1} rather than overwriting it"
            )
            nextIndex++
        }

        val fileName = keyVersionFileName(nextIndex)
        Files.writeString(dir.resolve(fileName), text)

        val updated = KeyVersionsDTO(
            known + KeyVersionInfo(
                index = nextIndex,
                timestamp = Instant.now().toString(),
                note = note,
                file = fileName
            )
        )
        Files.writeString(manifestFile, json.encodeToString(updated))
    }

    private fun readKeyVersions(path: Path): List<KeyVersionInfo> {
        val dir = keyVersionsDir(path)
        if (!dir.exists()) return emptyList()
        return reconcileKeyVersions(dir, parseKeyVersionManifest(keyVersionsManifest(path)) ?: emptyList())
            .also { reportKeyVersionDamage(dir, it) }
    }

    private val reportedKeyVersionDamage = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()

internal fun keyVersionDamage(versions: List<KeyVersionInfo>): List<String>

The marks a save made before 2026-08 can have left: an unparseable manifest then read as no history, so the next save renumbered from 0 and wrote over the oldest files. That shows up as missing indices, or as a version whose timestamp is earlier than the one before it. Reported once per directory and content on error,keyframer; what was overwritten cannot be recovered.

    internal fun keyVersionDamage(versions: List<KeyVersionInfo>): List<String> {
        val problems = ArrayList<String>()
        val indices = versions.map { it.index }.toSet()
        val missing = (0..(indices.maxOrNull() ?: -1)).filter { it !in indices }
        if (missing.isNotEmpty()) problems += "versions ${missing.joinToString()} are missing"
        versions.sortedBy { it.index }.zipWithNext().forEach { (a, b) ->
            val ta = runCatching { Instant.parse(a.timestamp) }.getOrNull()
            val tb = runCatching { Instant.parse(b.timestamp) }.getOrNull()
            if (ta != null && tb != null && tb.isBefore(ta))
                problems += "version ${b.index} (${b.timestamp}) is older than version ${a.index} (${a.timestamp})"
        }
        return problems
    }

    private fun reportKeyVersionDamage(dir: Path, versions: List<KeyVersionInfo>) {
        val problems = keyVersionDamage(versions)
        if (problems.isEmpty() || !reportedKeyVersionDamage.add("$dir|$problems")) return
        lprintln(
            "error,keyframer",
            "key version history $dir has gaps: ${problems.joinToString("; ")}. A save before 2026-08 " +
                "renumbered from 0 after an unreadable manifest and overwrote the oldest files; the versions " +
                "listed are what survives"
        )
    }

The manifest's rows, or null when a manifest is there but cannot be read — a distinction the callers need, because "no manifest" is ordinary (nothing saved yet, a hand-cleaned directory) while "unreadable manifest" is a fault to report and to preserve.

    private fun parseKeyVersionManifest(manifest: Path): List<KeyVersionInfo>? {
        if (!manifest.exists()) return emptyList()
        return runCatching { json.decodeFromString<KeyVersionsDTO>(Files.readString(manifest)).versions }
            .getOrElse { e ->
                lprintln(
                    "error,keyframer",
                    "unreadable key version manifest $manifest (${e.message ?: e::class.simpleName}); " +
                        "rebuilding the history from the numbered files beside it — the notes and " +
                        "timestamps it held are lost, the snapshots themselves are not"
                )
                null
            }
    }

Move an unreadable manifest aside instead of letting the next write erase it, so a person can still fish the notes out of a half-written file. Never reuses a name.

    private fun quarantineKeyVersionManifest(manifest: Path) {
        if (!manifest.exists()) return
        var target = manifest.resolveSibling("${manifest.name}.corrupt")
        var n = 1
        while (target.exists()) target = manifest.resolveSibling("${manifest.name}.corrupt-${n++}")
        Files.move(manifest, target)
        lprintln("error,keyframer", "kept the unreadable key version manifest as $target")
    }

The manifest's rows plus a rebuilt row for every numbered file no row claims. Manifest rows win on an index they already describe, since they carry the real note and timestamp.

    private fun reconcileKeyVersions(dir: Path, rows: List<KeyVersionInfo>): List<KeyVersionInfo> {
        val claimed = rows.mapTo(mutableSetOf()) { it.index }
        val recovered = keyVersionFilesOnDisk(dir)
            .filter { (index, _) -> index !in claimed }
            .map { (index, file) ->
                KeyVersionInfo(
                    index = index,
                    timestamp = Files.getLastModifiedTime(file).toInstant().toString(),
                    note = RECOVERED_NOTE,
                    file = file.name
                )
            }
        if (recovered.isNotEmpty()) {
            lprintln(
                "error,keyframer",
                "key version file(s) ${recovered.joinToString { it.file }} in $dir are not in the " +
                    "manifest; treating them as history, so the next save will not reuse their numbers"
            )
        }
        return (rows + recovered).sortedBy { it.index }
    }

Every "%06d.json" in a .versions directory, as index-to-file. Anything else is not ours.

    private fun keyVersionFilesOnDisk(dir: Path): List<Pair<Int, Path>> =
        dir.listDirectoryEntries("*.json")
            .mapNotNull { file ->
                file.name.removeSuffix(".json").toIntOrNull()
                    ?.takeIf { keyVersionFileName(it) == file.name }
                    ?.let { it to file }
            }
            .sortedBy { it.first }

    private fun keyVersionsDir(path: Path): Path =
        (path.parent ?: Path.of(".")).resolve("${path.name}.versions")

    private fun keyVersionsManifest(path: Path): Path =
        keyVersionsDir(path).resolve("manifest.json")

    private fun keyVersionFile(path: Path, version: Int): Path {
        val info = keyVersions(path).firstOrNull { it.index == version }
            ?: throw IllegalArgumentException("No key version $version for $path")
        return keyVersionsDir(path).resolve(info.file)
    }

    private fun keyVersionFileName(version: Int): String =
        "%06d.json".format(version)
}

fun ControlGroupBuilder.keyframeSlider

Put one Double local in a scope and on a visible slider, with either direction keeping the other two in step. UI gestures call set; applying a key calls set and moves the visible control.

fun ControlGroupBuilder.keyframeSlider(
    scope: KeyframeScope,
    name: String,
    label: String,
    min: Double = 0.0,
    max: Double = 1.0,
    step: Double = 0.01,
    law: ControlLaw = ControlLaw.LINEAR,
    lawExponent: Double = 2.0,
    lawCenter: Double = (min + max) / 2,
    significantDigits: Int = defaultSignificantDigits(law),
    get: () -> Double,
    set: (Double) -> Unit
): ControlValueHandle<Double> {
    val handle = slider(
        label = label,
        min = min,
        max = max,
        step = step,
        initialValue = get(),
        law = law,
        lawExponent = lawExponent,
        lawCenter = lawCenter,
        significantDigits = significantDigits,
        onUpdate = set
    )
    scope.doubleTarget(name, get) { value ->
        set(value)
        handle.set(value)
    }
    return handle
}

fun ControlGroupBuilder.keyframeFloatSlider

keyframeSlider for the Float locals most graphics code uses.

fun ControlGroupBuilder.keyframeFloatSlider(
    scope: KeyframeScope,
    name: String,
    label: String,
    min: Double = 0.0,
    max: Double = 1.0,
    step: Double = 0.01,
    law: ControlLaw = ControlLaw.LINEAR,
    lawExponent: Double = 2.0,
    lawCenter: Double = (min + max) / 2,
    significantDigits: Int = defaultSignificantDigits(law),
    get: () -> Float,
    set: (Float) -> Unit
): ControlValueHandle<Double> {
    val handle = slider(
        label = label,
        min = min,
        max = max,
        step = step,
        initialValue = get().toDouble(),
        law = law,
        lawExponent = lawExponent,
        lawCenter = lawCenter,
        significantDigits = significantDigits,
        onUpdate = { set(it.toFloat()) }
    )
    scope.floatTarget(name, get) { value ->
        set(value)
        handle.set(value.toDouble())
    }
    return handle
}

fun ControlGroupBuilder.keyframeCheckbox

A keyframed Boolean local and checkbox; the persisted lane is an interpolable 0/1 float.

fun ControlGroupBuilder.keyframeCheckbox(
    scope: KeyframeScope,
    name: String,
    label: String,
    get: () -> Boolean,
    set: (Boolean) -> Unit
): ControlValueHandle<Boolean> {
    val handle = checkbox(label, initialValue = get(), onUpdate = set)
    scope.booleanTarget(name, get) { value ->
        set(value)
        handle.set(value)
    }
    return handle
}

fun ControlGroupBuilder.keyframeVec2Control

A keyframed vec2 local and XY control.

fun ControlGroupBuilder.keyframeVec2Control(
    scope: KeyframeScope,
    name: String,
    label: String,
    xRange: Pair<Double, Double> = -1.0 to 1.0,
    yRange: Pair<Double, Double> = -1.0 to 1.0,
    step: Double = 0.01,
    get: () -> Vector2fc,
    set: (Vector2f) -> Unit
): ControlValueHandle<Vector2f> {
    val handle = vec2Control(label, Vector2f(get()), xRange, yRange, step, onUpdate = set)
    scope.vector2Target(name, get) { value ->
        set(Vector2f(value))
        handle.set(value)
    }
    return handle
}

fun ControlGroupBuilder.keyframeColorPicker

A keyframed RGBA vec4 local and colour picker.

fun ControlGroupBuilder.keyframeColorPicker(
    scope: KeyframeScope,
    name: String,
    label: String,
    get: () -> Vector4fc,
    set: (Vector4f) -> Unit
): ControlValueHandle<Vector4f> {
    val handle = colorPicker(label, Vector4f(get()), onUpdate = set)
    scope.vector4Target(name, get) { value ->
        set(Vector4f(value))
        handle.set(value)
    }
    return handle
}

fun ControlGroupBuilder.keyframeText

A keyframed string local and compact text control.

fun ControlGroupBuilder.keyframeText(
    scope: KeyframeScope,
    name: String,
    label: String,
    get: () -> String,
    set: (String) -> Unit
): ControlValueHandle<String> {
    val handle = text(label, initialValue = get(), onUpdate = set)
    scope.stringTarget(name, get) { value ->
        set(value)
        handle.set(value)
    }
    return handle
}

The KeyVersionInfo.note of a history row rebuilt from a numbered file that no manifest row described. Meant to read as wrong in the history table, because it is: the snapshot survived and the note did not.

private const val RECOVERED_NOTE = "(recovered from disk; the manifest row for this version was lost)"

enum class KeyDiffKind { ADDED, REMOVED, CHANGED }

How one name differs between two keys, always read left-to-right: ADDED means the right key has it and the left does not, REMOVED the other way round, CHANGED that both have it with unequal values. There is no "same" kind — unchanged names are left out of a diff entirely.

data class KeyDiffEntry

One line of a KeyDiff: what the name was on each side, and which way it moved.

data class KeyDiffEntry(
    /** The key name, as it appears in both snapshots (`@`-qualified if that is how it was registered). */
    val name: String,
    /** Whether this name was added, removed, or changed between left and right. */
    val kind: KeyDiffKind,
    /** The left-hand value; null for an `ADDED` entry, which had none. */
    val left: Any?,
    /** The right-hand value; null for a `REMOVED` entry. */
    val right: Any?
)

data class KeyDiff

The difference between two KeyframeScope.Keys: only the names that actually moved, sorted by name, ready to render. Produced by Key.diff.

Comparison is component-wise and exact for vectors, quaternions and camera poses, structural for a source block (same box and same source text), and plain == for everything else — no float tolerance anywhere, so a value that merely round-tripped through a save may still show as changed.

Those two rules disagree about NaN and about signed zero, in opposite directions, because one is a boxed equals and the other a primitive comparison. A bare number goes through == on Any?, which is java.lang.Double.equals and therefore a bit comparison: NaN equals NaN, and -0.0 does not equal 0.0. A vector/quaternion component goes through primitive Float ==, which is IEEE: NaN does not equal NaN, and -0.0 does equal 0.0. So a key holding NaN in a vector diffs as changed against an identical copy of itself, while a key holding a bare -0.0 diffs as changed against 0.0. Pinned by KeyframerTest's diffTreatsNaNAndSignedZeroOppositelyForANumberAndForAVectorComponent; left as-is rather than unified, because either rule is defensible alone and changing one would move what the KeyDiff table shows.

data class KeyDiff(
    /** Heading for the left-hand column — cosmetic, defaulting to `"left"` at the call site. */
    val leftName: String,
    /** Heading for the right-hand column. */
    val rightName: String,
    /** The changes, name-sorted; empty means the two keys agree. */
    val entries: List<KeyDiffEntry>
) : ToHTML {

override fun toString(): String

The diff as text, one line per change prefixed + (added), - (removed) or ~ (changed), and the single line "No key differences." when there are none.

    override fun toString(): String =
        if (entries.isEmpty()) "No key differences."
        else entries.joinToString("\n") { entry ->
            when (entry.kind) {
                KeyDiffKind.ADDED -> "+ ${entry.name}: ${keyframeValueText(entry.right)}"
                KeyDiffKind.REMOVED -> "- ${entry.name}: ${keyframeValueText(entry.left)}"
                KeyDiffKind.CHANGED -> "~ ${entry.name}: ${keyframeValueText(entry.left)} -> ${keyframeValueText(entry.right)}"
            }
        }

override fun toHTML(): String

The diff as a small dark-mode HTML table — one row per change, marker coloured green for added, red for removed and amber for changed, with a header counting the changes. What a box shows when a KeyDiff is the value on screen.

body — 143 lines, from line 1800
    override fun toHTML(): String {
        // The two value columns are named by the caller, so they are disambiguated before reaching
        // Table, which rejects a duplicate column name rather than half-decorating one of them.
        val leftCol = leftName
        val rightCol = if (rightName == leftName) "$rightName (right)" else rightName
        val markOf = { kind: KeyDiffKind ->
            when (kind) {
                KeyDiffKind.ADDED -> "+"
                KeyDiffKind.REMOVED -> "-"
                KeyDiffKind.CHANGED -> "~"
            }
        }
        val diff = Table(listOf("change", "key", leftCol, rightCol))
            .rows(
                entries.map { entry ->
                    listOf(
                        markOf(entry.kind),
                        entry.name,
                        keyframeValueHtml(entry.left),
                        keyframeValueHtml(entry.right),
                    )
                }
            )
            // added / removed / changed as roles, so the marker colours come from the design tokens.
            .roleBy("change") {
                when (it) {
                    "+" -> CellRole.OK
                    "-" -> CellRole.BAD
                    else -> CellRole.WARN
                }
            }
            .format(leftCol) { it?.toString() ?: "" }
            .format(rightCol) { it?.toString() ?: "" }
        val body = if (entries.isEmpty()) "{muted:No key differences.}" else diff.toHTML()
        return FieldMarkdown.card(
            """
            {accent:Key diff(${entries.size} changes)}

            $body
            """
        )
    }
}

private fun diffEntries(left: Map<String, Any?>, right: Map<String, Any?>): List<KeyDiffEntry> =
    (left.keys + right.keys).sorted().mapNotNull { name ->
        val hasLeft = left.containsKey(name)
        val hasRight = right.containsKey(name)
        when {
            !hasLeft && hasRight -> KeyDiffEntry(name, KeyDiffKind.ADDED, null, right[name])
            hasLeft && !hasRight -> KeyDiffEntry(name, KeyDiffKind.REMOVED, left[name], null)
            !keyValuesEqual(left[name], right[name]) -> KeyDiffEntry(name, KeyDiffKind.CHANGED, left[name], right[name])
            else -> null
        }
    }

private fun keyValuesEqual(a: Any?, b: Any?): Boolean =
    when {
        a is SourceBackedBlock<*> && b is SourceBackedBlock<*> -> a.boxId == b.boxId && a.source == b.source
        a is Vector2f && b is Vector2f -> a.x == b.x && a.y == b.y
        a is Vector3f && b is Vector3f -> a.x == b.x && a.y == b.y && a.z == b.z
        a is Vector4f && b is Vector4f -> a.x == b.x && a.y == b.y && a.z == b.z && a.w == b.w
        a is Quaternionf && b is Quaternionf -> a.x == b.x && a.y == b.y && a.z == b.z && a.w == b.w
        a is DualQuaternionf && b is DualQuaternionf ->
            a.real.x == b.real.x && a.real.y == b.real.y && a.real.z == b.real.z && a.real.w == b.real.w &&
                a.dual.x == b.dual.x && a.dual.y == b.dual.y && a.dual.z == b.dual.z && a.dual.w == b.dual.w
        a is CameraPose && b is CameraPose ->
            a.focusDistance == b.focusDistance && keyValuesEqual(a.transform, b.transform)
        else -> a == b
    }

private fun kotlinStringLiteral(text: String): String = buildString(text.length + 16) {
    append('"')
    text.forEach { c ->
        when (c) {
            '\\' -> append("\\\\")
            '"' -> append("\\\"")
            '\n' -> append("\\n")
            '\r' -> append("\\r")
            '\t' -> append("\\t")
            '$' -> append("\\$")
            else -> append(c)
        }
    }
    append('"')
}

private fun kotlinRawStringLiteral(text: String): String {
    // A raw string reads far better for a block of source, but Kotlin still expands `$` inside one,
    // so a body mentioning `$name` or `${…}` cannot go out raw: the pasted code would either fail to
    // compile (unresolved reference) or silently interpolate something else. kotlinStringLiteral
    // escapes `$` along with the quotes and newlines, so fall through to it in that case too.
    if (!text.contains("\"\"\"") && !text.endsWith("\"") && !text.contains('$')) return "\"\"\"$text\"\"\""
    return kotlinStringLiteral(text)
}

private fun kotlinNumberLiteral(value: Number): String =
    when (value) {
        is Byte -> value.toString() + ".toByte()"
        is Short -> value.toString() + ".toShort()"
        is Int -> value.toString()
        is Long -> value.toString() + "L"
        is Float -> {
            val f = value
            when {
                f.isNaN() -> "Float.NaN"
                f == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
                f == Float.NEGATIVE_INFINITY -> "Float.NEGATIVE_INFINITY"
                // The `f` suffix is what keeps the round trip honest. Without it a captured Float came back as
                // `1.5`, a *Double* literal, and since coerceEditedValue deliberately does not re-coerce
                // numbers the pasted code stored a Double where the capture held a Float.
                else -> value.toString() + "f"
            }
        }
        is Double -> {
            val d = value
            when {
                d.isNaN() -> "Double.NaN"
                d == Double.POSITIVE_INFINITY -> "Double.POSITIVE_INFINITY"
                d == Double.NEGATIVE_INFINITY -> "Double.NEGATIVE_INFINITY"
                else -> value.toString()
            }
        }
        else -> value.toDouble().toString()
    }

private fun kotlinValueLiteral(value: Any?): String =
    when (value) {
        null -> "null"
        is StackSLString -> "stackSLString(${kotlinStringLiteral(value.value)})"
        is SourceBackedBlock<*> -> kotlinRawStringLiteral(value.source)
        is String -> kotlinStringLiteral(value)
        is Byte, is Short, is Int, is Long, is Float, is Double -> kotlinNumberLiteral(value as Number)
        is Number -> value.toDouble().toString()
        is Vector2f -> "vec(${kotlinNumberLiteral(value.x)}, ${kotlinNumberLiteral(value.y)})"
        is Vector3f -> "vec(${kotlinNumberLiteral(value.x)}, ${kotlinNumberLiteral(value.y)}, ${kotlinNumberLiteral(value.z)})"
        is Vector4f -> "vec(${kotlinNumberLiteral(value.x)}, ${kotlinNumberLiteral(value.y)}, ${kotlinNumberLiteral(value.z)}, ${kotlinNumberLiteral(value.w)})"
        // Name the supported constructor, not value::class: a runtime subclass may be anonymous.
        is Quaternionf -> "${Quaternionf::class.qualifiedName}(${kotlinNumberLiteral(value.x)}, ${kotlinNumberLiteral(value.y)}, ${kotlinNumberLiteral(value.z)}, ${kotlinNumberLiteral(value.w)})"
        is DualQuaternionf -> "${DualQuaternionf::class.qualifiedName}(${kotlinValueLiteral(value.real)}, ${kotlinValueLiteral(value.dual)})"
        is CameraPose -> "${CameraPose::class.qualifiedName}(${kotlinValueLiteral(value.transform)}, ${kotlinNumberLiteral(value.focusDistance)})"
        else -> "/* unsupported ${value::class.java.name} */ null"
    }

val KeyframeScope.Key.h: String

A key's KeyframeScope.Key.toHTML table, for display() and anywhere else HTML is what is wanted.

body — 60 lines, from line 1945
val KeyframeScope.Key.h: String
    get() = toHTML()

private fun keyframeEsc(s: String): String = buildString(s.length) {
    s.forEach {
        when (it) {
            '&' -> append("&amp;")
            '<' -> append("&lt;")
            '>' -> append("&gt;")
            '"' -> append("&quot;")
            '\'' -> append("&#39;")
            else -> append(it)
        }
    }
}

private fun keyframeSourceBlockHtml(v: SourceBackedBlock<*>): String {
    val boxHtml = sourceBackedBlockBox(v.boxId)?.h
        ?: "<span style='color:#f88;'>missing box ${keyframeEsc(v.boxId)}</span>"
    return """
    <div style="display:grid;gap:3px;">
      <div><span style="color:#777;">sourceBlock in </span>$boxHtml</div>
      <pre style="margin:0;padding:4px 6px;border-left:2px solid #555;background:rgba(255,255,255,0.04);white-space:pre-wrap;color:#d4d4d4;">${keyframeEsc(v.source)}</pre>
    </div>
    """.trimIndent()
}

private fun keyframeValueHtml(v: Any?): String = when (v) {
    is SourceBackedBlock<*> -> keyframeSourceBlockHtml(v)
    is StackSLString -> v.value.h
    else -> v.h
}

private fun keyframeValueText(v: Any?): String = when (v) {
    null -> "null"
    is StackSLString -> v.value
    is SourceBackedBlock<*> -> "sourceBlock@${v.boxId} { ${v.source.replace('\n', ' ')} }"
    is Vector2f -> "vec(${v.x}, ${v.y})"
    is Vector3f -> "vec(${v.x}, ${v.y}, ${v.z})"
    is Vector4f -> "vec(${v.x}, ${v.y}, ${v.z}, ${v.w})"
    is Quaternionf -> "quat(${v.x}, ${v.y}, ${v.z}, ${v.w})"
    is DualQuaternionf -> "dualquat(real=quat(${v.real.x}, ${v.real.y}, ${v.real.z}, ${v.real.w}), dual=quat(${v.dual.x}, ${v.dual.y}, ${v.dual.z}, ${v.dual.w}))"
    is CameraPose -> "cameraPose(position=${v.position}, focus=${v.focusDistance})"
    else -> v.toString()
}

private fun keyframeKeyHtml(values: Map<String, Any?>): String {
    // keyframeValueHtml returns markup (a vector gets an axis widget), so the value column is
    // formatted rather than escaped.
    val listing = facts(values.entries.sortedBy { it.key }.map { (name, value) -> name to keyframeValueHtml(value) })
        .format("value") { it?.toString() ?: "" }

    return FieldMarkdown.card(
        """
        {accent:Key(${values.size} values)}

        ${listing.toHTML()}
        """
    )
}