[orx-keyframer] Add envelopes, remove repetitions
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# orx-keyframer
|
||||
|
||||
Create animated timelines by specifying properties and times in keyframes,
|
||||
then play it back at any speed (even backwards) automatically interpolating properties.
|
||||
Save, load, use mathematical expressions and callbacks. Powerful and highly reusable.
|
||||
Create animated timelines by specifying properties and times in keyframes, then play it back at any speed (even
|
||||
backwards) automatically interpolating properties. Save, load, use mathematical expressions and callbacks. Powerful and
|
||||
highly reusable.
|
||||
|
||||
What this allows you to do:
|
||||
|
||||
@@ -77,6 +77,7 @@ extend {
|
||||
drawer.circle(animation.position, animation.radius)
|
||||
}
|
||||
```
|
||||
|
||||
## Easing
|
||||
|
||||
All the easing functions of orx-easing are available
|
||||
@@ -115,17 +116,65 @@ All the easing functions of orx-easing are available
|
||||
- one
|
||||
- zero
|
||||
|
||||
## More expressive interface
|
||||
|
||||
orx-keyframer has two ways of programming key frames. The first is the `"x": <number>` style we have seen before. The
|
||||
second way uses a dictionary instead of a number value.
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"time": 0.0,
|
||||
"x": 320.0,
|
||||
"y": 240.0
|
||||
},
|
||||
{
|
||||
"time": 10.0,
|
||||
"easing": "cubic-out",
|
||||
"x": {
|
||||
"easing": "cubic-in-out",
|
||||
"value": 0.0
|
||||
},
|
||||
"y": {
|
||||
"duration": -5.0,
|
||||
"easing": "cubic-in",
|
||||
"value": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 20.0,
|
||||
"x": 640.0,
|
||||
"y": 480.0,
|
||||
"easing": "cubic-in-out"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Inside the value dictionary one can set `value`, `easing`, `duration` and `envelope`.
|
||||
|
||||
* `value` the target value, required value
|
||||
* `easing` easing method that overrides the key's easing method, optional value
|
||||
* `duration` an optional duration for the animation, set to `0` to jump from the previous
|
||||
value to the new value, a negative value will start the interpolation before `time`. A positive value
|
||||
wil start the interpolation at `time` and end at `time + duration`
|
||||
* `envelope` optional 2-point envelope that modifies the playback of the animation. The default envelope is
|
||||
`[0.0, 1.0]`. Reverse playback is achieved by supplying `[1.0, 0.0]`. To start the animation later try `[0.1, 1.0]`,
|
||||
to end the animation earlier try `[0.0, 0.9]`
|
||||
|
||||
## Advanced features
|
||||
|
||||
orx-keyframer uses two file formats. A `SIMPLE` format and a `FULL` format. For reference check the [example full format .json](src/demo/resources/demo-full-01.json) and the [example program](src/demo/kotlin/DemoFull01.kt).
|
||||
The full format adds a `parameters` block and a `prototypes` block.
|
||||
orx-keyframer uses two file formats. A `SIMPLE` format and a `FULL` format. For reference check
|
||||
the [example full format .json](src/demo/resources/demo-full-01.json) and
|
||||
the [example program](src/demo/kotlin/DemoFull01.kt). The full format adds a `parameters` block and a `prototypes`
|
||||
block.
|
||||
|
||||
[Repeats](src/demo/resources/demo-simple-repetitions-01.json), simple key repeating mechanism
|
||||
|
||||
[Expressions](src/demo/resources/demo-simple-expressions-01.json), expression mechanism. Currently uses values `r` to indicate repeat index and `t` the last used key time, `v` the last used value (for the animated attribute).
|
||||
[Expressions](src/demo/resources/demo-simple-expressions-01.json), expression mechanism. Currently uses values `r` to
|
||||
indicate repeat index and `t` the last used key time, `v` the last used value (for the animated attribute).
|
||||
|
||||
Supported functions in expressions:
|
||||
|
||||
- `min(x, y)`, `max(x, y)`
|
||||
- `cos(x)`, `sin(x)`, `acos(x)`, `asin(x)`, `tan(x)`, `atan(x)`, `atan2(y, x)`
|
||||
- `abs(x)`, `saturate(x)`
|
||||
@@ -139,33 +188,41 @@ Supported functions in expressions:
|
||||
[Parameters and prototypes](src/demo/resources/demo-full-01.json)
|
||||
|
||||
<!-- __demos__ -->
|
||||
|
||||
## Demos
|
||||
|
||||
### DemoFull01
|
||||
|
||||
[source code](src/demo/kotlin/DemoFull01.kt)
|
||||
|
||||

|
||||
|
||||
### DemoScrub01
|
||||
|
||||
[source code](src/demo/kotlin/DemoScrub01.kt)
|
||||
|
||||

|
||||
|
||||
### DemoSimple01
|
||||
|
||||
[source code](src/demo/kotlin/DemoSimple01.kt)
|
||||
|
||||

|
||||
|
||||
### DemoSimple02
|
||||
|
||||
[source code](src/demo/kotlin/DemoSimple02.kt)
|
||||
|
||||

|
||||
|
||||
### DemoSimpleExpressions01
|
||||
|
||||
[source code](src/demo/kotlin/DemoSimpleExpressions01.kt)
|
||||
|
||||

|
||||
|
||||
### DemoSimpleRepetitions01
|
||||
|
||||
[source code](src/demo/kotlin/DemoSimpleRepetitions01.kt)
|
||||
|
||||

|
||||
|
||||
@@ -8,10 +8,9 @@ fun main() = application {
|
||||
program {
|
||||
class Animation: Keyframer() {
|
||||
val position by Vector2Channel(arrayOf("x", "y"))
|
||||
val radius by DoubleChannel("x")
|
||||
}
|
||||
val animation = Animation()
|
||||
animation.loadFromJson(URL(resourceUrl("/demo-simple-repetitions-01.json")))
|
||||
animation.loadFromJson(URL(resourceUrl("/demo-envelope-01.json")))
|
||||
if (System.getProperty("takeScreenshot") == "true") {
|
||||
extend(SingleScreenshot()) {
|
||||
this.outputFile = System.getProperty("screenshotPath")
|
||||
@@ -19,7 +18,7 @@ fun main() = application {
|
||||
}
|
||||
extend {
|
||||
animation(seconds)
|
||||
drawer.circle(animation.position, animation.radius)
|
||||
drawer.circle(animation.position, 100.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
orx-keyframer/src/demo/resources/demo-envelope-01.json
Normal file
25
orx-keyframer/src/demo/resources/demo-envelope-01.json
Normal file
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"time": 0.0,
|
||||
"x": 320.0,
|
||||
"y": 240.0
|
||||
},
|
||||
{
|
||||
"time": 10.0,
|
||||
"easing": "cubic-in-out",
|
||||
"x": {
|
||||
"envelope": [0.5, 1.0],
|
||||
"value": 0.0
|
||||
},
|
||||
"y": {
|
||||
"envelope": [0.4, 1.0],
|
||||
"value": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 20.0,
|
||||
"x": 640.0,
|
||||
"y": 480.0,
|
||||
"easing": "cubic-in-out"
|
||||
}
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
[
|
||||
{
|
||||
"time": 0.0,
|
||||
"x": 320.0,
|
||||
"y": 240.0,
|
||||
"radius": 0.0
|
||||
},
|
||||
{
|
||||
"time": 3.0,
|
||||
"repeat": {
|
||||
"count": 5,
|
||||
"keys": [
|
||||
{
|
||||
"duration": 1.0,
|
||||
"easing": "cubic-in-out",
|
||||
"x": 10.0,
|
||||
"y": 4.0,
|
||||
"radius": 400
|
||||
},
|
||||
{
|
||||
"duration": 1.0,
|
||||
"easing": "cubic-in-out",
|
||||
"x": 630.0,
|
||||
"y": 470.0,
|
||||
"radius": 40
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -20,8 +20,8 @@ DECIMAL : 'Decimal';
|
||||
STRING : 'String';
|
||||
|
||||
// Identifiers
|
||||
ID : [_]*[a-zA-Z][A-Za-z0-9_]* ;
|
||||
FUNCTION_ID : [_]*[a-z][A-Za-z0-9_]* ;
|
||||
ID : [$_]*[a-zA-Z][A-Za-z0-9_]* ;
|
||||
FUNCTION_ID : [$_]*[a-z][A-Za-z0-9_]* ;
|
||||
|
||||
// Literals
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.openrndr.extra.keyframer
|
||||
import org.antlr.v4.runtime.*
|
||||
import org.antlr.v4.runtime.tree.ParseTreeWalker
|
||||
import org.antlr.v4.runtime.tree.TerminalNode
|
||||
import org.openrndr.extra.keyframer.antlr.*
|
||||
import org.openrndr.extra.keyframer.antlr.KeyLangLexer
|
||||
import org.openrndr.extra.keyframer.antlr.KeyLangParser
|
||||
import org.openrndr.extra.keyframer.antlr.KeyLangParserBaseListener
|
||||
import org.openrndr.extra.noise.uniform
|
||||
import org.openrndr.math.*
|
||||
import java.util.*
|
||||
@@ -357,20 +359,6 @@ fun evaluateExpression(
|
||||
functions: FunctionExtensions = FunctionExtensions.EMPTY
|
||||
): Double? {
|
||||
val lexer = KeyLangLexer(CharStreams.fromString(input))
|
||||
//
|
||||
// lexer.removeErrorListeners()
|
||||
// lexer.addErrorListener(object : BaseErrorListener() {
|
||||
// override fun syntaxError(
|
||||
// recognizer: Recognizer<*, *>?,
|
||||
// offendingSymbol: Any?,
|
||||
// line: Int,
|
||||
// charPositionInLine: Int,
|
||||
// msg: String?,
|
||||
// e: RecognitionException?
|
||||
// ) {
|
||||
// println("syntax error!")
|
||||
// }
|
||||
// })
|
||||
val parser = KeyLangParser(CommonTokenStream(lexer))
|
||||
parser.removeErrorListeners()
|
||||
parser.addErrorListener(object : BaseErrorListener() {
|
||||
|
||||
@@ -2,14 +2,12 @@ package org.openrndr.extra.keyframer
|
||||
|
||||
import org.openrndr.extras.easing.Easing
|
||||
import org.openrndr.extras.easing.EasingFunction
|
||||
import org.openrndr.math.map
|
||||
|
||||
class Key(val time: Double, val value: Double, val easing: EasingFunction)
|
||||
internal val defaultEnvelope = doubleArrayOf(0.0, 1.0)
|
||||
|
||||
class Key(val time: Double, val value: Double, val easing: EasingFunction, val envelope: DoubleArray = defaultEnvelope)
|
||||
|
||||
enum class Hold {
|
||||
HoldNone,
|
||||
HoldSet,
|
||||
HoldAll
|
||||
}
|
||||
|
||||
class KeyframerChannel {
|
||||
val keys = mutableListOf<Key>()
|
||||
@@ -18,14 +16,17 @@ class KeyframerChannel {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
fun add(time: Double, value: Double?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) {
|
||||
if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) {
|
||||
lastValue()?.let {
|
||||
keys.add(Key(time, it, Easing.Linear.function))
|
||||
}
|
||||
fun add(
|
||||
time: Double,
|
||||
value: Double?,
|
||||
easing: EasingFunction = Easing.Linear.function,
|
||||
envelope: DoubleArray = defaultEnvelope
|
||||
) {
|
||||
require(envelope.size >= 2) {
|
||||
"envelope should contain at least 2 entries"
|
||||
}
|
||||
value?.let {
|
||||
keys.add(Key(time, it, easing))
|
||||
keys.add(Key(time, it, easing, envelope))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +66,8 @@ class KeyframerChannel {
|
||||
val rightKey = keys[rightIndex]
|
||||
val leftKey = keys[leftIndex]
|
||||
val t0 = (time - leftKey.time) / (rightKey.time - leftKey.time)
|
||||
val e0 = rightKey.easing(t0, 0.0, 1.0, 1.0)
|
||||
val te = t0.map(rightKey.envelope[0], rightKey.envelope[1], 0.0, 1.0, clamp = true)
|
||||
val e0 = rightKey.easing(te, 0.0, 1.0, 1.0)
|
||||
leftKey.value * (1.0 - e0) + rightKey.value * (e0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,8 @@ class KeyframerChannelQuaternion {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
fun add(time: Double, value: Quaternion?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) {
|
||||
if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) {
|
||||
lastValue()?.let {
|
||||
keys.add(KeyQuaternion(time, it, Easing.Linear.function))
|
||||
}
|
||||
}
|
||||
fun add(time: Double, value: Quaternion?, easing: EasingFunction = Easing.Linear.function) {
|
||||
|
||||
value?.let {
|
||||
keys.add(KeyQuaternion(time, it, easing))
|
||||
}
|
||||
|
||||
@@ -13,12 +13,7 @@ class KeyframerChannelVector3 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
fun add(time: Double, value: Vector3?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) {
|
||||
if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) {
|
||||
lastValue()?.let {
|
||||
keys.add(KeyVector3(time, it, Easing.Linear.function))
|
||||
}
|
||||
}
|
||||
fun add(time: Double, value: Vector3?, easing: EasingFunction = Easing.Linear.function) {
|
||||
value?.let {
|
||||
keys.add(KeyVector3(time, it, easing))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import java.lang.IllegalStateException
|
||||
import java.lang.NullPointerException
|
||||
import java.net.URL
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
@@ -258,7 +257,6 @@ open class Keyframer {
|
||||
expressionContext.putAll(parameters)
|
||||
expressionContext["t"] = 0.0
|
||||
|
||||
|
||||
fun easingFunctionFromName(easingCandidate: String): EasingFunction {
|
||||
return when (easingCandidate) {
|
||||
"linear" -> Easing.Linear.function
|
||||
@@ -346,9 +344,20 @@ open class Keyframer {
|
||||
throw ExpressionException("error in $path.'easing': ${e.message ?: ""}")
|
||||
}
|
||||
|
||||
val hold = Hold.HoldNone
|
||||
val envelope = try {
|
||||
when (val candidate = computed["envelope"]) {
|
||||
null -> defaultEnvelope
|
||||
is DoubleArray -> candidate
|
||||
is List<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray()
|
||||
is Array<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray()
|
||||
else -> error("unknown envelope for '$candidate")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
throw ExpressionException("error in $path.'envelope': ${e.message ?: ""}")
|
||||
}
|
||||
|
||||
val reservedKeys = setOf("time", "easing", "hold")
|
||||
|
||||
val reservedKeys = setOf("time", "easing", "envelope")
|
||||
|
||||
for (channelCandidate in computed.filter { it.key !in reservedKeys }) {
|
||||
if (channelCandidate.key in channelKeys) {
|
||||
@@ -385,6 +394,14 @@ open class Keyframer {
|
||||
else -> error("unknown easing for '$candidate'")
|
||||
}
|
||||
|
||||
val dictEnvelope = when (val candidate = valueMap["envelope"]) {
|
||||
null -> envelope
|
||||
is DoubleArray -> candidate
|
||||
is List<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray()
|
||||
is Array<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray()
|
||||
else -> error("unknown envelope for '$candidate")
|
||||
|
||||
}
|
||||
val dictDuration = try {
|
||||
when (val candidate = valueMap["duration"]) {
|
||||
null -> null
|
||||
@@ -400,12 +417,14 @@ open class Keyframer {
|
||||
|
||||
if (dictDuration != null) {
|
||||
if (dictDuration <= 0.0) {
|
||||
channel.add(max(lastTime, time + dictDuration), lastValue, Easing.Linear.function, hold)
|
||||
channel.add(time, value, dictEasing, hold)
|
||||
channel.add(max(lastTime, time + dictDuration), lastValue, Easing.Linear.function, defaultEnvelope)
|
||||
channel.add(time, value, dictEasing, dictEnvelope)
|
||||
} else {
|
||||
channel.add(time, lastValue, Easing.Linear.function, hold)
|
||||
channel.add(time + dictDuration, value, dictEasing, hold)
|
||||
channel.add(time, lastValue, Easing.Linear.function, defaultEnvelope)
|
||||
channel.add(time + dictDuration, value, dictEasing, dictEnvelope)
|
||||
}
|
||||
} else {
|
||||
channel.add(time, value, dictEasing, dictEnvelope)
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -420,39 +439,12 @@ open class Keyframer {
|
||||
} catch (e: ExpressionException) {
|
||||
throw ExpressionException("error in $path.'${channelCandidate.key}': ${e.message ?: ""}")
|
||||
}
|
||||
channel.add(time, value, easing, hold)
|
||||
channel.add(time, value, easing, envelope)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTime = time + duration
|
||||
expressionContext["t"] = lastTime
|
||||
|
||||
if (computed.containsKey("repeat")) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val repeatObject = computed["repeat"] as? Map<String, Any> ?: error("'repeat' should be a map")
|
||||
val count = try {
|
||||
when (val candidate = repeatObject["count"]) {
|
||||
null -> 1
|
||||
is Int -> candidate
|
||||
is Double -> candidate.toInt()
|
||||
is String -> evaluateExpression(candidate, expressionContext, functions)?.roundToInt()
|
||||
?: error("cannot evaluate expression for count: '$candidate'")
|
||||
else -> error("unknown value type for count: '$candidate")
|
||||
}
|
||||
} catch (e: ExpressionException) {
|
||||
throw ExpressionException("error in $path.repeat.'count': ${e.message ?: ""}")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val repeatKeys = repeatObject["keys"] as? List<Map<String, Any>> ?: error("no repeat keys")
|
||||
|
||||
for (i in 0 until count) {
|
||||
expressionContext["rep"] = i.toDouble()
|
||||
for (repeatKey in repeatKeys) {
|
||||
handleKey(repeatKey, "$path.repeat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ((index, key) in keys.withIndex()) {
|
||||
|
||||
Reference in New Issue
Block a user