
Frame-driven video composition system using composable functions where animations are pure functions of frame index; includes preview player, interpolation/spring easing, MP4 export with FFmpeg, audio mixing.
Frame-driven video animation for Compose Multiplatform.
Komotion lets you build video compositions as @Composable functions where every animation is a pure function of the current frame number. This makes animations deterministic, scrubbable, and trivially exportable to MP4.
Think Remotion — but for Kotlin and Compose.
| Module | Targets | Description |
|---|---|---|
komotion-core |
Android, iOS, Desktop, wasmJs | Core API: Composition, Sequence, interpolate, spring, LocalFrame
|
komotion-player |
Android, iOS, Desktop, wasmJs | Embedded player with play/pause, scrub, loop, and frame counter |
komotion-export-desktop |
Desktop (JVM) | MP4 export via FFmpeg with audio mixing and video frame extraction |
// Define your animation as a composable
@Composable
fun HelloVideo() {
val opacity = animateFloatAsFrame(0..30, 0f..1f, easing = spring())
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
Text(
"Hello, Komotion!",
color = Color.White,
fontSize = 48.sp,
modifier = Modifier.graphicsLayer(alpha = opacity)
)
}
}
// Preview it with the built-in player
@Composable
fun Preview() {
val composition = Composition(width = 1920, height = 1080, durationInFrames = 60, fps = 30)
KomotionPlayer(composition) {
HelloVideo()
}
}
// Export to MP4 (desktop only)
suspend fun export() {
val composition = Composition(width = 1920, height = 1080, durationInFrames = 60, fps = 30)
val renderer = FfmpegFrameRenderer()
renderer.render(composition, "output.mp4") {
HelloVideo()
}
}Every composable in a Komotion composition reads the current frame via LocalFrame:
val frame = LocalFrame.current // 0-based frame index
val comp = LocalComposition.current // width, height, fps, durationInFramesTime-window a section of your composition. Children see LocalFrame offset to 0-based within the sequence:
Sequence(from = 0, durationInFrames = 30) {
// LocalFrame.current here is 0..29
val opacity = animateFloatAsFrame(0..30, 0f..1f)
Text("Intro", modifier = Modifier.graphicsLayer(alpha = opacity))
}
Sequence(from = 30, durationInFrames = 30) {
// LocalFrame.current here is 0..29
Text("Main content")
}Pure functions — deterministic, no Compose context needed:
interpolate(frame, 0..30, 0f..1f) // Float
interpolateInt(frame, 0..60, 0..100) // Int
interpolateColor(frame, 0..30, Color.Black, Color.White) // Color
interpolateDp(frame, 0..30, 0.dp, 16.dp) // DpComposable wrappers that read LocalFrame automatically:
val opacity = animateFloatAsFrame(0..30, 0f..1f)
val count = animateIntAsFrame(0..60, 0..100)
val bg = animateColorAsFrame(0..30, Color.Black, Color.White)
val padding = animateDpAsFrame(0..30, 0.dp, 16.dp)All accept an easing parameter.
Physics-based spring compatible with all interpolation functions:
spring() // Default: slightly bouncy (damping=0.7)
spring(damping = 0.3f) // Very bouncy, overshoots
spring(damping = 1.0f) // Critically damped, no overshoot
spring(damping = 2.0f, stiffness = 50f) // Overdamped, sluggish
// Use with any interpolation
val scale = animateFloatAsFrame(0..20, 0.8f..1f, easing = spring(damping = 0.5f))Declare audio files to be mixed into the exported MP4:
val audioTracks = listOf(
AudioTrack(file = "/path/to/intro.wav", startFrame = 0),
AudioTrack(file = "/path/to/main.wav", startFrame = 90),
)
renderer.render(composition, "output.mp4", audioTracks = audioTracks) {
MyComposition()
}Audio is mixed via FFmpeg adelay + amix during export. Preview playback is silent.
Embed pre-rendered MP4 clips in your composition (desktop export only):
@Composable
fun DemoSegment(videoPath: String) {
val frame = LocalFrame.current
val videoFrame = rememberVideoFrame(videoFile = videoPath, frameIndex = frame)
Box(Modifier.fillMaxSize()) {
if (videoFrame != null) {
Image(bitmap = videoFrame, contentDescription = null, modifier = Modifier.fillMaxSize())
} else {
Box(Modifier.fillMaxSize().background(Color.Gray)) // Preview placeholder
}
}
}// build.gradle.kts
commonMain.dependencies {
implementation("dev.boling.komotion:komotion-core:0.1.0")
implementation("dev.boling.komotion:komotion-player:0.1.0")
}
// Desktop only
val desktopMain by getting {
dependencies {
implementation("dev.boling.komotion:komotion-export-desktop:0.1.0")
}
}In your settings.gradle.kts:
includeBuild("/path/to/komotion") {
dependencySubstitution {
substitute(module("dev.boling.komotion:komotion-core")).using(project(":komotion-core"))
substitute(module("dev.boling.komotion:komotion-player")).using(project(":komotion-player"))
substitute(module("dev.boling.komotion:komotion-export-desktop")).using(project(":komotion-export-desktop"))
}
}Copyright 2026 Stewart Boling
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Frame-driven video animation for Compose Multiplatform.
Komotion lets you build video compositions as @Composable functions where every animation is a pure function of the current frame number. This makes animations deterministic, scrubbable, and trivially exportable to MP4.
Think Remotion — but for Kotlin and Compose.
| Module | Targets | Description |
|---|---|---|
komotion-core |
Android, iOS, Desktop, wasmJs | Core API: Composition, Sequence, interpolate, spring, LocalFrame
|
komotion-player |
Android, iOS, Desktop, wasmJs | Embedded player with play/pause, scrub, loop, and frame counter |
komotion-export-desktop |
Desktop (JVM) | MP4 export via FFmpeg with audio mixing and video frame extraction |
// Define your animation as a composable
@Composable
fun HelloVideo() {
val opacity = animateFloatAsFrame(0..30, 0f..1f, easing = spring())
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
Text(
"Hello, Komotion!",
color = Color.White,
fontSize = 48.sp,
modifier = Modifier.graphicsLayer(alpha = opacity)
)
}
}
// Preview it with the built-in player
@Composable
fun Preview() {
val composition = Composition(width = 1920, height = 1080, durationInFrames = 60, fps = 30)
KomotionPlayer(composition) {
HelloVideo()
}
}
// Export to MP4 (desktop only)
suspend fun export() {
val composition = Composition(width = 1920, height = 1080, durationInFrames = 60, fps = 30)
val renderer = FfmpegFrameRenderer()
renderer.render(composition, "output.mp4") {
HelloVideo()
}
}Every composable in a Komotion composition reads the current frame via LocalFrame:
val frame = LocalFrame.current // 0-based frame index
val comp = LocalComposition.current // width, height, fps, durationInFramesTime-window a section of your composition. Children see LocalFrame offset to 0-based within the sequence:
Sequence(from = 0, durationInFrames = 30) {
// LocalFrame.current here is 0..29
val opacity = animateFloatAsFrame(0..30, 0f..1f)
Text("Intro", modifier = Modifier.graphicsLayer(alpha = opacity))
}
Sequence(from = 30, durationInFrames = 30) {
// LocalFrame.current here is 0..29
Text("Main content")
}Pure functions — deterministic, no Compose context needed:
interpolate(frame, 0..30, 0f..1f) // Float
interpolateInt(frame, 0..60, 0..100) // Int
interpolateColor(frame, 0..30, Color.Black, Color.White) // Color
interpolateDp(frame, 0..30, 0.dp, 16.dp) // DpComposable wrappers that read LocalFrame automatically:
val opacity = animateFloatAsFrame(0..30, 0f..1f)
val count = animateIntAsFrame(0..60, 0..100)
val bg = animateColorAsFrame(0..30, Color.Black, Color.White)
val padding = animateDpAsFrame(0..30, 0.dp, 16.dp)All accept an easing parameter.
Physics-based spring compatible with all interpolation functions:
spring() // Default: slightly bouncy (damping=0.7)
spring(damping = 0.3f) // Very bouncy, overshoots
spring(damping = 1.0f) // Critically damped, no overshoot
spring(damping = 2.0f, stiffness = 50f) // Overdamped, sluggish
// Use with any interpolation
val scale = animateFloatAsFrame(0..20, 0.8f..1f, easing = spring(damping = 0.5f))Declare audio files to be mixed into the exported MP4:
val audioTracks = listOf(
AudioTrack(file = "/path/to/intro.wav", startFrame = 0),
AudioTrack(file = "/path/to/main.wav", startFrame = 90),
)
renderer.render(composition, "output.mp4", audioTracks = audioTracks) {
MyComposition()
}Audio is mixed via FFmpeg adelay + amix during export. Preview playback is silent.
Embed pre-rendered MP4 clips in your composition (desktop export only):
@Composable
fun DemoSegment(videoPath: String) {
val frame = LocalFrame.current
val videoFrame = rememberVideoFrame(videoFile = videoPath, frameIndex = frame)
Box(Modifier.fillMaxSize()) {
if (videoFrame != null) {
Image(bitmap = videoFrame, contentDescription = null, modifier = Modifier.fillMaxSize())
} else {
Box(Modifier.fillMaxSize().background(Color.Gray)) // Preview placeholder
}
}
}// build.gradle.kts
commonMain.dependencies {
implementation("dev.boling.komotion:komotion-core:0.1.0")
implementation("dev.boling.komotion:komotion-player:0.1.0")
}
// Desktop only
val desktopMain by getting {
dependencies {
implementation("dev.boling.komotion:komotion-export-desktop:0.1.0")
}
}In your settings.gradle.kts:
includeBuild("/path/to/komotion") {
dependencySubstitution {
substitute(module("dev.boling.komotion:komotion-core")).using(project(":komotion-core"))
substitute(module("dev.boling.komotion:komotion-player")).using(project(":komotion-player"))
substitute(module("dev.boling.komotion:komotion-export-desktop")).using(project(":komotion-export-desktop"))
}
}Copyright 2026 Stewart Boling
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.