Unified high-level API for FFmpeg/FFprobe offering asynchronous cancellable sessions, structured logs/progress, typed FFprobe models, command and filter DSLs, Okio-mounted I/O, and raw-argument escape hatch.
FFmpegKMP is a Kotlin Multiplatform wrapper around FFmpeg, FFprobe, and a Compose-first FFplay API. Its goal is to expose one Kotlin-first API for media processing and inspection across Apple platforms, Android, JVM desktop, and the browser through Kotlin/JS or Kotlin/Wasm backed by the same WebAssembly runtime.
The final library should let applications use the same high-level API on every supported platform while still allowing raw FFmpeg arguments when the typed API does not cover an advanced use case.
The intended developer experience includes:
The common API exposes coroutine sessions and immutable commands:
FFprobeClient().use { probe ->
val media = probe.inspect("input.mp4")
}
FFmpegClient().use { ffmpeg ->
val result = ffmpeg.execute(FFmpegCommand.build {
overwrite()
input("input.mp4")
videoCodec("libx264")
audioCodec("aac")
output("output.mp4")
})
}Mounted I/O is backed by Okio. A FileHandle is read and written on demand at
the offsets requested by FFmpeg, so no full temporary-file staging is needed:
val input = FileSystem.SYSTEM.openReadOnly("input.mp4".toPath())
val output = FileSystem.SYSTEM.openReadWrite("output.mp4".toPath())
val io = CommandIo {
input("mounted-input.mp4", input)
output("mounted-output.mp4", output)
}
FFmpegClient().use { ffmpeg ->
ffmpeg.execute(listOf("-i", "mounted-input.mp4", "mounted-output.mp4"), io)
}Okio Source and Sink mounts are non-seekable streams on every platform.
Formats that seek to patch their own header (regular, non-fragmented MP4 chief
among them) need a seekable destination: mount a FileHandle directly, or
opt into output(path, sink, Staging()) to have FFmpeg write to a real
temporary file that gets copied to the sink once the command succeeds. Staging
is a caller choice, not implicit bridge behavior, so streaming-friendly
formats (-f mpegts, or MP4 with -movflags frag_keyframe+empty_moov) can
mount a plain Sink and avoid the extra write.
On Android, overloads accept file descriptors, ParcelFileDescriptor,
AssetFileDescriptor, content Uri, and Java input/output streams; seekable
descriptors use random access and pipe-backed descriptors automatically fall
back to stream semantics.
Long-running commands use bounded queues and retained-result capture. Pass a
CommandRuntimeLimits to FFmpegClient or FFprobeClient to tune the native
event handoff and stdout, stderr, and log limits. Live event collectors apply
backpressure; ExecutionResult.captureStatus reports retained output that was
truncated by those limits.
The module layers, binding backends, and native build flow are described in the architecture documentation.
The same AudioLevel(volume, muted) drives encoding and playback, so what a user
hears in a preview is what an export renders. Tracks are addressed by their
audio-relative index (a:N) in both.
// Encode: keep the second audio track at half volume and the commentary muted.
val command = FFmpegCommand {
input("movie.mkv")
map("0:v:0")
mapAudio(track = 1) // output audio track 0
mapAudio(track = 2) // output audio track 1
audioLevel(AudioLevel(volume = 0.5), outputTrack = 0)
audioLevel(AudioLevel.Muted, outputTrack = 1) // present, but silent
audioCodec("aac")
output("out.mp4")
}
// Or mix tracks into one with the filters DSL (sums levels, like the player).
val mix = FilterGraph {
mixAudio(
listOf(
audioTrack(0, 0) to AudioLevel.Unchanged,
audioTrack(1) to AudioLevel(volume = 0.3),
),
label = "mix",
)
}ffplay plays video with its audio in sync (audio is the master clock) and
exposes the same controls: setVolume, setMuted, selectAudioTrack,
setAudioTrackEnabled, and per-track levels on FFplayPlayer.
The optional player artifact plays or decodes audio on its own with live controls.
AudioPlayer outputs through AudioTrack (Android), Java Sound (desktop) or
AVAudioEngine (Apple); AudioDecoder gives the same mix as float PCM for your
own pipeline, decoding on a thread of its own behind suspending read and seek. Both use FFmpeg's libraries directly rather than the command-line
tools, so they run alongside FFmpegClient commands instead of queueing behind
them. They are not available in the browser yet; there FFplayPlayer plays audio itself.
val player = AudioPlayer.open("movie.mkv")
player.tracks // codec, language, title, channels... per audio track
player.selectTrack(1) // switch language
player.setTrackEnabled(2, true) // or mix in commentary
player.setTrackVolume(2, 0.4)
player.setVolume(0.8)
player.setMuted(true)
player.play()
player.seekTo(30.seconds)
player.state.collect { /* PAUSED, PLAYING, ENDED, FAILED, CLOSED */ }On iOS, set the app's AVAudioSession category before playing; the player
leaves it unchanged.
The optional filters artifact includes color-managed HDR mappings for HDR10
BT.2020/PQ output. Android runtime builds expose P010 to MediaCodec encoders;
callers must still select HEVC Main10 HDR10 only on Android 13+ devices whose
codecs advertise P010 and the HDR10 profile.
| Module | Android | JVM | JS | Wasm | iOS | macOS | tvOS | watchOS |
|---|---|---|---|---|---|---|---|---|
bindings |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
core |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffmpeg |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffprobe |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
filters |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
codec |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffplay |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
player |
✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
codec holds the Compose-free frame model (VideoFrame, FrameFormat),
frame-accurate decoding (VideoDecoder, through WebCodecs in the browser), encoding
(MediaWriter, SDR only in the browser), and the source,
decoder and stream types (MediaSource, DecoderThreads, VideoInfo, …) that
ffplay builds on and names with typealiases.
ffplay excludes tvOS and watchOS because Compose UI artifacts are not
published for those targets. player publishes JS and Wasm artifacts so shared
code compiles, but its decoder and player are not available in the browser yet; ffplay plays
audio there through its own worker.
FFmpegKMP/
├── build-logic/ Gradle conventions and shared target policy
├── ffmpeg/ Pinned FFmpeg source checkout
├── native-build/ Android, Apple, JVM, and Wasm build pipelines
├── bindings/ One KMP module for native, JNI, and Wasm interop
├── library/ Public core, FFmpeg, FFprobe, codec, FFplay, and filter APIs
└── samples/ Android, desktop, iOS, and web examples
build-logic/ contains the included Gradle build and convention plugins.ffmpeg/ is reserved for the pinned FFmpeg Git submodule.native-build/ produces traceable, target-specific FFmpeg artifacts locally
on the user's machine.bindings/ is one KMP module containing generated interop code and internal
adapters. JVM and Android share one JNI C++/Java binding implementation.library/ contains the platform-neutral API exposed to consumers.samples/ contains FFmpegKMP Studio, a shared Compose Multiplatform multi-clip
editor with Android, iOS, desktop, and browser launchers. Its selected-clip
preview is wired through FFplayPlayer and FFplaySurface.Native artifacts are generated locally through target-specific pipelines. See the native build documentation for the intended build model and reproducibility requirements.
Add the APIs you use from Maven Central to commonMain:
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.github.aftrolle.ffmpegkmp:ffmpeg:<version>")
implementation("io.github.aftrolle.ffmpegkmp:ffprobe:<version>")
implementation("io.github.aftrolle.ffmpegkmp:ffplay:<version>")
// Optional typed filter graph DSL:
implementation("io.github.aftrolle.ffmpegkmp:filters:<version>")
// Optional audio playback and PCM decoding:
implementation("io.github.aftrolle.ffmpegkmp:player:<version>")
}
}The Maven modules contain Kotlin APIs and binding declarations only. Build the matching local runtime from the same Git tag, then add it to the final app:
The high-level modules bring in core and bindings transitively. Add
io.github.aftrolle.ffmpegkmp:bindings:<version> directly only when using its
low-level API. On JVM and Android, the binding artifact exposes JavaCPP as a
transitive API dependency because its generated public declarations use
JavaCPP types. FFmpeg and JNI binaries are still supplied only by the separate
local runtime.
git clone --recurse-submodules --branch <release-tag> https://github.com/afTrolle/FFmpegKMP.git
cd FFmpegKMP
./gradlew assembleAndroidSampleBinaries # Android AAR with JNI/FFmpeg .so files
./gradlew assembleDesktopSampleBinaries # Current-host JNI + shared libraries
./gradlew assembleIosSampleBinaries # iOS device and simulator static archives
./gradlew assembleWebSampleBinaries # .mjs/.wasm runtime (requires Emscripten)./gradlew assembleSampleBinaries runs all four on a macOS machine with the
Android, Xcode, desktop, and Emscripten toolchains installed. Each sample build
also invokes its own preparation task automatically. See
Using FFmpegKMP in an application for the generated paths
and platform-specific integration steps. Generated native binaries remain
ignored local outputs and are never part of a Maven publication.
core, ffmpeg, ffprobe, and filters contain the session API, scheduler,
command/tokenizer DSL, typed JSON model, and filter AST.fftools entry objects.emconfigure and emcc toolchain.Most FFmpegKMP-authored code is licensed under Apache License 2.0,
while the bindings module is licensed under
LGPL-2.1-or-later. FFmpeg and other third-party components retain their own
licences.
FFmpegKMP's Maven packages contain the high-level libraries and the separately licensed bindings module, but not compiled FFmpeg libraries, JNI shims, or WebAssembly modules. Anyone distributing locally generated binaries or an application containing them is responsible for the applicable open-source, patent, and platform requirements. Read the full licensing and distribution policy and third-party notices before distributing an output.
FFmpegKMP is a Kotlin Multiplatform wrapper around FFmpeg, FFprobe, and a Compose-first FFplay API. Its goal is to expose one Kotlin-first API for media processing and inspection across Apple platforms, Android, JVM desktop, and the browser through Kotlin/JS or Kotlin/Wasm backed by the same WebAssembly runtime.
The final library should let applications use the same high-level API on every supported platform while still allowing raw FFmpeg arguments when the typed API does not cover an advanced use case.
The intended developer experience includes:
The common API exposes coroutine sessions and immutable commands:
FFprobeClient().use { probe ->
val media = probe.inspect("input.mp4")
}
FFmpegClient().use { ffmpeg ->
val result = ffmpeg.execute(FFmpegCommand.build {
overwrite()
input("input.mp4")
videoCodec("libx264")
audioCodec("aac")
output("output.mp4")
})
}Mounted I/O is backed by Okio. A FileHandle is read and written on demand at
the offsets requested by FFmpeg, so no full temporary-file staging is needed:
val input = FileSystem.SYSTEM.openReadOnly("input.mp4".toPath())
val output = FileSystem.SYSTEM.openReadWrite("output.mp4".toPath())
val io = CommandIo {
input("mounted-input.mp4", input)
output("mounted-output.mp4", output)
}
FFmpegClient().use { ffmpeg ->
ffmpeg.execute(listOf("-i", "mounted-input.mp4", "mounted-output.mp4"), io)
}Okio Source and Sink mounts are non-seekable streams on every platform.
Formats that seek to patch their own header (regular, non-fragmented MP4 chief
among them) need a seekable destination: mount a FileHandle directly, or
opt into output(path, sink, Staging()) to have FFmpeg write to a real
temporary file that gets copied to the sink once the command succeeds. Staging
is a caller choice, not implicit bridge behavior, so streaming-friendly
formats (-f mpegts, or MP4 with -movflags frag_keyframe+empty_moov) can
mount a plain Sink and avoid the extra write.
On Android, overloads accept file descriptors, ParcelFileDescriptor,
AssetFileDescriptor, content Uri, and Java input/output streams; seekable
descriptors use random access and pipe-backed descriptors automatically fall
back to stream semantics.
Long-running commands use bounded queues and retained-result capture. Pass a
CommandRuntimeLimits to FFmpegClient or FFprobeClient to tune the native
event handoff and stdout, stderr, and log limits. Live event collectors apply
backpressure; ExecutionResult.captureStatus reports retained output that was
truncated by those limits.
The module layers, binding backends, and native build flow are described in the architecture documentation.
The same AudioLevel(volume, muted) drives encoding and playback, so what a user
hears in a preview is what an export renders. Tracks are addressed by their
audio-relative index (a:N) in both.
// Encode: keep the second audio track at half volume and the commentary muted.
val command = FFmpegCommand {
input("movie.mkv")
map("0:v:0")
mapAudio(track = 1) // output audio track 0
mapAudio(track = 2) // output audio track 1
audioLevel(AudioLevel(volume = 0.5), outputTrack = 0)
audioLevel(AudioLevel.Muted, outputTrack = 1) // present, but silent
audioCodec("aac")
output("out.mp4")
}
// Or mix tracks into one with the filters DSL (sums levels, like the player).
val mix = FilterGraph {
mixAudio(
listOf(
audioTrack(0, 0) to AudioLevel.Unchanged,
audioTrack(1) to AudioLevel(volume = 0.3),
),
label = "mix",
)
}ffplay plays video with its audio in sync (audio is the master clock) and
exposes the same controls: setVolume, setMuted, selectAudioTrack,
setAudioTrackEnabled, and per-track levels on FFplayPlayer.
The optional player artifact plays or decodes audio on its own with live controls.
AudioPlayer outputs through AudioTrack (Android), Java Sound (desktop) or
AVAudioEngine (Apple); AudioDecoder gives the same mix as float PCM for your
own pipeline, decoding on a thread of its own behind suspending read and seek. Both use FFmpeg's libraries directly rather than the command-line
tools, so they run alongside FFmpegClient commands instead of queueing behind
them. They are not available in the browser yet; there FFplayPlayer plays audio itself.
val player = AudioPlayer.open("movie.mkv")
player.tracks // codec, language, title, channels... per audio track
player.selectTrack(1) // switch language
player.setTrackEnabled(2, true) // or mix in commentary
player.setTrackVolume(2, 0.4)
player.setVolume(0.8)
player.setMuted(true)
player.play()
player.seekTo(30.seconds)
player.state.collect { /* PAUSED, PLAYING, ENDED, FAILED, CLOSED */ }On iOS, set the app's AVAudioSession category before playing; the player
leaves it unchanged.
The optional filters artifact includes color-managed HDR mappings for HDR10
BT.2020/PQ output. Android runtime builds expose P010 to MediaCodec encoders;
callers must still select HEVC Main10 HDR10 only on Android 13+ devices whose
codecs advertise P010 and the HDR10 profile.
| Module | Android | JVM | JS | Wasm | iOS | macOS | tvOS | watchOS |
|---|---|---|---|---|---|---|---|---|
bindings |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
core |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffmpeg |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffprobe |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
filters |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
codec |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
ffplay |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
player |
✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
codec holds the Compose-free frame model (VideoFrame, FrameFormat),
frame-accurate decoding (VideoDecoder, through WebCodecs in the browser), encoding
(MediaWriter, SDR only in the browser), and the source,
decoder and stream types (MediaSource, DecoderThreads, VideoInfo, …) that
ffplay builds on and names with typealiases.
ffplay excludes tvOS and watchOS because Compose UI artifacts are not
published for those targets. player publishes JS and Wasm artifacts so shared
code compiles, but its decoder and player are not available in the browser yet; ffplay plays
audio there through its own worker.
FFmpegKMP/
├── build-logic/ Gradle conventions and shared target policy
├── ffmpeg/ Pinned FFmpeg source checkout
├── native-build/ Android, Apple, JVM, and Wasm build pipelines
├── bindings/ One KMP module for native, JNI, and Wasm interop
├── library/ Public core, FFmpeg, FFprobe, codec, FFplay, and filter APIs
└── samples/ Android, desktop, iOS, and web examples
build-logic/ contains the included Gradle build and convention plugins.ffmpeg/ is reserved for the pinned FFmpeg Git submodule.native-build/ produces traceable, target-specific FFmpeg artifacts locally
on the user's machine.bindings/ is one KMP module containing generated interop code and internal
adapters. JVM and Android share one JNI C++/Java binding implementation.library/ contains the platform-neutral API exposed to consumers.samples/ contains FFmpegKMP Studio, a shared Compose Multiplatform multi-clip
editor with Android, iOS, desktop, and browser launchers. Its selected-clip
preview is wired through FFplayPlayer and FFplaySurface.Native artifacts are generated locally through target-specific pipelines. See the native build documentation for the intended build model and reproducibility requirements.
Add the APIs you use from Maven Central to commonMain:
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.github.aftrolle.ffmpegkmp:ffmpeg:<version>")
implementation("io.github.aftrolle.ffmpegkmp:ffprobe:<version>")
implementation("io.github.aftrolle.ffmpegkmp:ffplay:<version>")
// Optional typed filter graph DSL:
implementation("io.github.aftrolle.ffmpegkmp:filters:<version>")
// Optional audio playback and PCM decoding:
implementation("io.github.aftrolle.ffmpegkmp:player:<version>")
}
}The Maven modules contain Kotlin APIs and binding declarations only. Build the matching local runtime from the same Git tag, then add it to the final app:
The high-level modules bring in core and bindings transitively. Add
io.github.aftrolle.ffmpegkmp:bindings:<version> directly only when using its
low-level API. On JVM and Android, the binding artifact exposes JavaCPP as a
transitive API dependency because its generated public declarations use
JavaCPP types. FFmpeg and JNI binaries are still supplied only by the separate
local runtime.
git clone --recurse-submodules --branch <release-tag> https://github.com/afTrolle/FFmpegKMP.git
cd FFmpegKMP
./gradlew assembleAndroidSampleBinaries # Android AAR with JNI/FFmpeg .so files
./gradlew assembleDesktopSampleBinaries # Current-host JNI + shared libraries
./gradlew assembleIosSampleBinaries # iOS device and simulator static archives
./gradlew assembleWebSampleBinaries # .mjs/.wasm runtime (requires Emscripten)./gradlew assembleSampleBinaries runs all four on a macOS machine with the
Android, Xcode, desktop, and Emscripten toolchains installed. Each sample build
also invokes its own preparation task automatically. See
Using FFmpegKMP in an application for the generated paths
and platform-specific integration steps. Generated native binaries remain
ignored local outputs and are never part of a Maven publication.
core, ffmpeg, ffprobe, and filters contain the session API, scheduler,
command/tokenizer DSL, typed JSON model, and filter AST.fftools entry objects.emconfigure and emcc toolchain.Most FFmpegKMP-authored code is licensed under Apache License 2.0,
while the bindings module is licensed under
LGPL-2.1-or-later. FFmpeg and other third-party components retain their own
licences.
FFmpegKMP's Maven packages contain the high-level libraries and the separately licensed bindings module, but not compiled FFmpeg libraries, JNI shims, or WebAssembly modules. Anyone distributing locally generated binaries or an application containing them is responsible for the applicable open-source, patent, and platform requirements. Read the full licensing and distribution policy and third-party notices before distributing an output.