keyframeTidy.kt
src/jvmMain/kotlin/afield/animation/keyframes/keyframeTidy.kt · 173 lines
package afield.animation.keyframes
import afield.ai.conversation.images
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.custom
import afield.ui.notation.with
import afield.ui.output.sliders
import afield.audio.synth.*
import afield.data.table.times
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.max
KeyframeEvaluator.simplify and .smooth, which do what the editor's Simplify and Smooth sliders do (planSimplify and smoothRunValues in keyframe_editor.js).
private val ANCHORING = setOf("hold", "custom")
internal fun simplifyKeys
The fewest of keys (sorted by time) that stay within tolerance of curve. Keys go back in one
at a time, each where the simplified curve is furthest from the original, until it is within
tolerance everywhere. A key that stays becomes a continuous bezier whose handles follow the
original's slope there; locked, hold and custom keys stay exactly as they are, and so does the key
a hold or a custom segment arrives at.
kindOf is "hold", "custom", "linear" or "bezier" for a type; segment is the value at a time of
the segment between two keys.
body — 80 lines, from line 43
internal fun simplifyKeys(
keys: List<Keyframe>,
tolerance: Double,
kindOf: (String) -> String,
curve: (Double) -> Double,
segment: (Keyframe, Keyframe, Double) -> Double,
): List<Keyframe> {
val n = keys.size
if (n < 3) return keys
val asIs = BooleanArray(n) { keys[it].locked || kindOf(keys[it].type) in ANCHORING }
val kept = BooleanArray(n) { asIs[it] || it == 0 || it == n - 1 || kindOf(keys[it - 1].type) in ANCHORING }
// The original curve, at its keys and eight times along each segment.
val refT = buildList {
for (i in 0 until n - 1) {
val a = keys[i].time
val b = keys[i + 1].time
for (s in 0 until 8) add(a + (b - a) * s / 8.0)
}
add(keys.last().time)
}.toDoubleArray()
val refV = DoubleArray(refT.size) { curve(refT[it]) }
fun prevKept(i: Int): Int { for (j in i - 1 downTo 0) if (kept[j]) return j; return -1 }
fun nextKept(i: Int): Int { for (j in i + 1 until n) if (kept[j]) return j; return -1 }
// The key at i as it would be with its kept neighbours either side.
fun shape(i: Int): Keyframe {
val kf = keys[i]
if (asIs[i]) return kf
val a = prevKept(i)
val b = nextKept(i)
val spans = listOfNotNull(
if (a >= 0) kf.time - keys[a].time else null,
if (b >= 0) keys[b].time - kf.time else null,
).filter { it > TIDY_EPSILON }
if (spans.isEmpty()) return kf
val near = spans.min()
val w = near * 0.25
val t0 = if (i > 0) kf.time - w else kf.time
val t1 = if (i < n - 1) kf.time + w else kf.time
val m = if (t1 - t0 > TIDY_EPSILON) (curve(t1) - curve(t0)) / (t1 - t0) else 0.0
val l = near / 3
return kf.copy(type = "bezierContinuous", handles = Handles(Handle(-l, -m * l), Handle(l, m * l)))
}
class Segment(val b: Int, val err: Double, val at: Double, var stuck: Boolean = false)
fun measure(a: Int): Segment {
val b = nextKept(a)
val start = shape(a)
val end = shape(b)
if (kindOf(start.type) == "custom") return Segment(b, 0.0, start.time)
var err = 0.0
var at = start.time
for (k in refT.indices) {
val t = refT[k]
if (t <= start.time + TIDY_EPSILON || t >= end.time - TIDY_EPSILON) continue
val d = abs(segment(start, end, t) - refV[k])
if (d > err) { err = d; at = t }
}
return Segment(b, err, at)
}
val segments = HashMap<Int, Segment>()
for (i in 0 until n - 1) if (kept[i]) segments[i] = measure(i)
while (true) {
val (a, seg) = segments.entries.filter { !it.value.stuck }.maxByOrNull { it.value.err } ?: break
if (seg.err <= tolerance) break
var j = -1
for (i in a + 1 until seg.b) {
if (!kept[i] && (j < 0 || abs(keys[i].time - seg.at) < abs(keys[j].time - seg.at))) j = i
}
if (j < 0) { seg.stuck = true; continue }
kept[j] = true
// j changes the spans, so the handles, of the keys either side: re-measure what touches them.
listOf(prevKept(a), a, j, seg.b).forEach { if (it >= 0 && it < n - 1) segments[it] = measure(it) }
}
return (0 until n).filter { kept[it] }.map { shape(it) }
}
internal fun smoothKeys(keys: List<Keyframe>, width: Double): List<Keyframe>
The values of keys (sorted by time) smoothed by a Gaussian in time of standard deviation
width. The ends and locked keys keep their values, and the curve is reflected through the ends
so that a key near one is not pulled toward the middle.
body — 44 lines, from line 129
internal fun smoothKeys(keys: List<Keyframe>, width: Double): List<Keyframe> {
val n = keys.size
if (n < 3 || !(width > TIDY_EPSILON)) return keys
val times = DoubleArray(n) { keys[it].time }
val values = DoubleArray(n) { keys[it].value }
// Each key stands for the time around it, so uneven spacing does not tilt the average; an end
// stands for its reflection's side too.
val weightOf = DoubleArray(n) {
val span = when (it) {
0 -> times[1] - times[0]
n - 1 -> times[n - 1] - times[n - 2]
else -> (times[it + 1] - times[it - 1]) / 2
}
max(span, TIDY_EPSILON)
}
val reach = width * 3
// The Gaussian, lowered to reach zero at [reach], so a key just inside or outside it on one side
// and not the other does not tip the average.
val edge = exp(-4.5)
val t0 = times[0]
val v0 = values[0]
val tn = times[n - 1]
val vn = values[n - 1]
return keys.mapIndexed { i, kf ->
if (i == 0 || i == n - 1 || kf.locked) return@mapIndexed kf
val ti = times[i]
var sum = 0.0
var total = 0.0
fun add(t: Double, v: Double, w: Double) {
val d = t - ti
if (abs(d) > reach) return
val g = w * (exp(-(d * d) / (2 * width * width)) - edge)
sum += g * v
total += g
}
for (j in 0 until n) add(times[j], values[j], weightOf[j])
// The images of the keys reflected through each end.
for (j in 1 until n) add(2 * t0 - times[j], 2 * v0 - values[j], weightOf[j])
for (j in 0 until n - 1) add(2 * tn - times[j], 2 * vn - values[j], weightOf[j])
if (total > 0) kf.copy(value = sum / total) else kf
}
}
private const val TIDY_EPSILON = 1e-6