
Embed FFmpeg to transcode, remux, extract frames, build filter graphs and stream decoded frames via an asynchronous API. Single-call operations, typed errors, built-in binaries.
Video and audio for Kotlin Multiplatform. Open a media file, change it, save it.
Things people build with it:
.mkv into an .mp4 without re-encoding itFFmpeg does the actual work, and it is already compiled into the library for every platform.
You do not install FFmpeg. You do not add a Gradle plugin or touch linker settings. There is no
ffmpeg command being launched behind your back, and no console output to parse. You add one
dependency and call Kotlin functions.
Documentation · a guide per task, from your first transcode to building filter graphs.
commonMain.dependencies {
implementation("io.github.yuroyami:kiteffmpeg:0.1.0")
}That goes in the sourceSets block you already have, and it is the entire setup. Every target you
declare gets FFmpeg automatically: the artifact for each platform carries its own FFmpeg build
(about 10 MB) and its own linker settings.
This reads input.mp4, scales it down, tweaks the colour, adds a vignette, re-encodes the video
and audio, and writes output.mp4. Video and audio are handled together in a single pass, and
memory stays flat whether the file is one minute or three hours.
Transcoder.transcode(
input = "input.mp4",
output = "output.mp4",
spec = VideoEncoderSpec(
codec = CodecId("mpeg4"),
width = 320, height = 180,
frameRate = Rational(30, 1),
bitrateBps = 1_500_000,
),
videoFilter = "scale=320:180,hue=b=0.1,vignette,format=yuv420p",
audioSpec = AudioEncoderSpec(codec = CodecId.Aac),
audioFilter = "volume=0.8",
onProgress = { p -> println("encoded ${p.framesEncoded} frames") },
)That filter string is FFmpeg's own syntax, so anything you already know how to write for
ffmpeg -vf works here unchanged.
Set videoCopy or audioCopy if you want to keep a stream exactly as it is instead of
re-encoding it. That is much faster and loses nothing.
Errors come back as one FFmpegException wrapping a sealed FFmpegError, so "this device has no
AAC encoder" is a case you can match on in code, not a string you have to read.
FFmpeg has its own vocabulary and this page uses it. Here is the whole of it, in plain terms.
| Word | What it means | Everyday version |
|---|---|---|
| stream | One track inside a file | The video track, or the English audio track |
| packet | A chunk of still-compressed data | One small piece of the video track |
| frame | One decoded picture, or a slice of sound | A single image you could display |
| demux | Split a file into its streams | Unpack the box |
| decode | Turn packets into frames | Unzip a picture so you can look at it |
| encode | Turn frames back into packets | Zip the picture back up |
| mux | Write streams into one file | Pack the box again |
| transcode | Decode, then encode differently | Re-save it smaller, or in another format |
| remux | Move streams to a new container, no re-encoding | Change the box, keep the contents |
| filter | A step that changes frames | Scale, blur, watermark, adjust colour |
| pts | The timestamp on a frame | When this frame should appear |
You do not need any of it for the example above. It matters once you start reading frames yourself.
These are suspend functions, so call them from a coroutine. Frame readers are Kotlin Flows, so
you collect them.
| What you want to do | Call | Guide |
|---|---|---|
| Re-save a video smaller, or in another format | Transcoder.transcode(...) |
Transcoding |
| Cut a clip between two times | transcode(..., startMicros, endMicros) |
Transcoding |
| Change the file type without re-encoding | Remuxer.remux(...) |
Remuxing |
| Grab a single picture from a video | MediaSource.extractFrame(...) |
Decoding |
| Read every frame yourself | MediaSource.decodedFrames(...) |
Decoding |
| Read video and audio frames together | MediaSource.decodeStreams(...) |
Decoding |
| Scale, blur, watermark, adjust colour |
FilterGraph.buildVideo / buildVideoMulti
|
Filtering |
| Build a file out of frames you made |
MediaSink plus Frame.ofVideo / ofAudio
|
Encoding and muxing |
| Ask what this build supports |
FFmpeg.hasEncoder(...) / hasFilter(...)
|
Platform support |
Two details that catch people out. Cut times are microseconds into the video, counted from the
start of the content, not the raw numbers stored in the file. And a filter chain names its inputs
[in0], [in1] and so on, with a single output called [out].
MediaSource.open("input.mp4").use { source ->
val video = source.primaryVideo ?: error("no video track")
source.decodedFrames(video).collect { frame ->
val info = frame.info
println("pts=${info.ptsSeconds}s ${info.width}x${info.height}")
}
}You cannot collect two of these at once from the same file: they would both try to move the read
position, so the second one is rejected. Use decodeStreams(...) when you want video and audio
together.
Building a media player? The API above reads a file front to back, which is right for
converting and wrong for playback: a player needs audio and video to advance separately, and to
jump around while both are running. There is a second, lower-level API for that, behind an opt-in
annotation (@KiteFFmpegLowLevelApi) because it hands you objects you must free yourself.
KitePlayer is built on it. If you are not writing a
player, stay with the API above.
| Targets | |
|---|---|
| Plays real media |
macosArm64, iosArm64, iosSimulatorArm64, the Android AAR (minSdk 26, arm64-v8a and x86_64), linuxX64, linuxArm64, mingwX64
|
| Plays media, once you supply the wasm module |
wasmJs. Reading and decoding work, including seeking. Writing files (encode, mux) and filtering are refused by design |
| Builds, nothing has run |
macosX64, iosX64, and the androidNative* targets, which are for Kotlin/Native on Android and are not what a normal Android app uses |
| Placeholder |
js. The code compiles and you can ask it what it supports (nothing), but every media call throws FFmpegError.Unsupported
|
All of these publish at 0.1.0.
Android and iOS play real media on real phones: this is the engine under KitePlayer, which is device-tested on both, down to per-frame GPU timings on a Redmi Note 8. What those platforms do not have is an automated device job in this repository's CI (nobody runs a phone farm here), so their evidence is hand-verified and app-shipped rather than green-on-every-push. Desktop and Windows are the reverse: CI-verified on every push.
One thing to plan around: the JVM jar carries a macOS arm64 native library and only that one, so a JVM app on Linux or Windows resolves the artifact and then gets the typed unavailable placeholder. Per-target detail is in Platform support.
js is a deliberate placeholder: a build that silently did nothing would be worse than one that
tells you it cannot.
wasmJs is not a placeholder. It carries a real playback backend over a generated binding.
FFmpeg cannot be linked into the same binary in a browser, so it arrives as a separate wasm module
you load once before anything else:
KiteFFmpegWeb.load("/kite.mjs") // or attach() a module the page already instantiated
check(FFmpeg.identity.isAcceptable)Calls before that throw KiteFFmpegWeb.NotLoaded. Two things to know before choosing it: the
module is not published with the artifact, so you build it yourself with
:kiteffmpeg:buildFFmpegForWasm* (needs emscripten); and the Kotlin side is tested against a fake
codec module, which proves the binding reads the right fields and proves nothing about the built
artifact. A real browser run against a real module has not been recorded yet.
| Not available | What that means for you |
|---|---|
| A JVM distribution beyond macOS arm64 | Linux and Windows JVM apps get the typed unavailable placeholder, not a codec. |
| Writing or filtering media on the web | Both web targets refuse it. wasmJs can read and decode; it cannot produce a file. js refuses everything. |
| Any GPL FFmpeg | There is no GPL build and no way to swap the embedded one. Shipping GPL binaries would make your whole app GPL-3.0, which is not a choice a library should make for you. |
libx264, libx265, libsvtav1, libopus, libmp3lame
|
No third-party encoder is linked. mpeg4 is the software video baseline and aac the audio one. Decoding is far wider than encoding. |
| Choosing a bitstream filter yourself | These fix up packet formatting when moving between container types. You cannot pick one by hand, but the common ones are built in and FFmpeg applies them for you during a copy. |
| Hardware-accelerated decoding | Hardware encoding works. On virtual machines and CI runners pass allow_sw, where the encoder exists but the physical chip does not. |
https |
The embedded build has no TLS backend. Use http, a local file, or link your own FFmpeg tree. |
| An automated device job in CI | Android and iOS are verified by hand and by a shipping app, not by a phone farm on every push. Desktop and Windows are CI-verified. |
| A frozen API | 0.1.x is pre-1.0, so signatures can still change. What you do get: every public declaration is explicit, and a snapshot of the whole API is checked on every push, so a change fails the build here rather than surprising you at your call site. |
This list was read out of the shipped binary itself, not copied from a build script.
| macOS | Linux / Windows | iOS | Android | |
|---|---|---|---|---|
| Video, software |
mpeg4, mjpeg, png, apng, h263, h263p
|
same | same | same |
| Video, hardware |
h264_videotoolbox, hevc_videotoolbox, prores_videotoolbox
|
none | none |
h264_mediacodec, hevc_mediacodec
|
| Audio |
aac, flac, pcm_s16le, pcm_s24le, pcm_f32le
|
same | same | same |
FFmpeg builds differ, so probe rather than assume: FFmpeg.hasEncoder(...), FFmpeg.hasFilter(...),
FFmpeg.versions and FFmpeg.buildConfiguration all report what is actually linked. Filters marked
deps="gpl" upstream, such as eq, are absent here.
If the linked FFmpeg ever disagrees with the headers this artifact was compiled against, the first call fails with a typed error naming both identities, rather than returning plausible wrong numbers ten frames later. Full codec, container and filter lists are in Platform support.
KiteFFmpeg's own code is Apache-2.0. The embedded FFmpeg is LGPL-2.1-or-later and dav1d is BSD-2-Clause. There is no GPL anywhere, so your application's own licence is untouched and what you link is safe for closed source and for the App Store.
Shipping an app that statically links LGPL code puts three obligations on you: say your app uses
FFmpeg under the LGPL, keep the corresponding FFmpeg source available, and let users relink against
a modified FFmpeg. The first is satisfied by a notice with the licence text, which the JVM jar
carries at META-INF/licenses/kiteffmpeg-ffmpeg/. The second is satisfied by the source tarball
attached to each release. NOTICE states this precisely; Licensing is
the full guide.
The sample is a Kotlin/Native CLI over the whole API. It picks its encoder by probing the linked FFmpeg, so it works against the embedded LGPL build and against your own tree alike.
./gradlew :kiteffmpeg-sample:linkDebugExecutableMacosArm64
KEXE=kiteffmpeg-sample/build/bin/macosArm64/debugExecutable/kiteffmpeg-sample.kexe
$KEXE transcode in.mp4 out.mp4 "scale=1280:720" -acopy # also: info, probe, thumbnail, remux
./gradlew :kiteffmpeg:macosArm64Test # or linuxX64Test / mingwX64Test
scripts/e2e.sh "$KEXE"The C helper layer builds and tests on its own, outside Gradle:
cd native/kitecodec-c
./scripts/build-host.sh plain && ./scripts/run-c-tests.sh plain # also asan, tsan
./scripts/symbol-audit.sh # what the archive needs, exports and keeps private
./scripts/replay-corpus.sh # every committed fuzz seed, under ASan and UBSanWhat each instrument can and cannot prove is written out in native/kitecodec-c/README.md. Every build step is in Getting started; the binding design and timestamp rules are in About KiteFFmpeg.
Apache-2.0. See NOTICE and CHANGELOG.md.
Not affiliated with the FFmpeg project. FFmpeg is a trademark of Fabrice Bellard; this is an independent Kotlin binding that links FFmpeg's LGPL libraries.
Part of the Kite family: KiteCore, KitePDF, KiteImage, KiteQR.
Video and audio for Kotlin Multiplatform. Open a media file, change it, save it.
Things people build with it:
.mkv into an .mp4 without re-encoding itFFmpeg does the actual work, and it is already compiled into the library for every platform.
You do not install FFmpeg. You do not add a Gradle plugin or touch linker settings. There is no
ffmpeg command being launched behind your back, and no console output to parse. You add one
dependency and call Kotlin functions.
Documentation · a guide per task, from your first transcode to building filter graphs.
commonMain.dependencies {
implementation("io.github.yuroyami:kiteffmpeg:0.1.0")
}That goes in the sourceSets block you already have, and it is the entire setup. Every target you
declare gets FFmpeg automatically: the artifact for each platform carries its own FFmpeg build
(about 10 MB) and its own linker settings.
This reads input.mp4, scales it down, tweaks the colour, adds a vignette, re-encodes the video
and audio, and writes output.mp4. Video and audio are handled together in a single pass, and
memory stays flat whether the file is one minute or three hours.
Transcoder.transcode(
input = "input.mp4",
output = "output.mp4",
spec = VideoEncoderSpec(
codec = CodecId("mpeg4"),
width = 320, height = 180,
frameRate = Rational(30, 1),
bitrateBps = 1_500_000,
),
videoFilter = "scale=320:180,hue=b=0.1,vignette,format=yuv420p",
audioSpec = AudioEncoderSpec(codec = CodecId.Aac),
audioFilter = "volume=0.8",
onProgress = { p -> println("encoded ${p.framesEncoded} frames") },
)That filter string is FFmpeg's own syntax, so anything you already know how to write for
ffmpeg -vf works here unchanged.
Set videoCopy or audioCopy if you want to keep a stream exactly as it is instead of
re-encoding it. That is much faster and loses nothing.
Errors come back as one FFmpegException wrapping a sealed FFmpegError, so "this device has no
AAC encoder" is a case you can match on in code, not a string you have to read.
FFmpeg has its own vocabulary and this page uses it. Here is the whole of it, in plain terms.
| Word | What it means | Everyday version |
|---|---|---|
| stream | One track inside a file | The video track, or the English audio track |
| packet | A chunk of still-compressed data | One small piece of the video track |
| frame | One decoded picture, or a slice of sound | A single image you could display |
| demux | Split a file into its streams | Unpack the box |
| decode | Turn packets into frames | Unzip a picture so you can look at it |
| encode | Turn frames back into packets | Zip the picture back up |
| mux | Write streams into one file | Pack the box again |
| transcode | Decode, then encode differently | Re-save it smaller, or in another format |
| remux | Move streams to a new container, no re-encoding | Change the box, keep the contents |
| filter | A step that changes frames | Scale, blur, watermark, adjust colour |
| pts | The timestamp on a frame | When this frame should appear |
You do not need any of it for the example above. It matters once you start reading frames yourself.
These are suspend functions, so call them from a coroutine. Frame readers are Kotlin Flows, so
you collect them.
| What you want to do | Call | Guide |
|---|---|---|
| Re-save a video smaller, or in another format | Transcoder.transcode(...) |
Transcoding |
| Cut a clip between two times | transcode(..., startMicros, endMicros) |
Transcoding |
| Change the file type without re-encoding | Remuxer.remux(...) |
Remuxing |
| Grab a single picture from a video | MediaSource.extractFrame(...) |
Decoding |
| Read every frame yourself | MediaSource.decodedFrames(...) |
Decoding |
| Read video and audio frames together | MediaSource.decodeStreams(...) |
Decoding |
| Scale, blur, watermark, adjust colour |
FilterGraph.buildVideo / buildVideoMulti
|
Filtering |
| Build a file out of frames you made |
MediaSink plus Frame.ofVideo / ofAudio
|
Encoding and muxing |
| Ask what this build supports |
FFmpeg.hasEncoder(...) / hasFilter(...)
|
Platform support |
Two details that catch people out. Cut times are microseconds into the video, counted from the
start of the content, not the raw numbers stored in the file. And a filter chain names its inputs
[in0], [in1] and so on, with a single output called [out].
MediaSource.open("input.mp4").use { source ->
val video = source.primaryVideo ?: error("no video track")
source.decodedFrames(video).collect { frame ->
val info = frame.info
println("pts=${info.ptsSeconds}s ${info.width}x${info.height}")
}
}You cannot collect two of these at once from the same file: they would both try to move the read
position, so the second one is rejected. Use decodeStreams(...) when you want video and audio
together.
Building a media player? The API above reads a file front to back, which is right for
converting and wrong for playback: a player needs audio and video to advance separately, and to
jump around while both are running. There is a second, lower-level API for that, behind an opt-in
annotation (@KiteFFmpegLowLevelApi) because it hands you objects you must free yourself.
KitePlayer is built on it. If you are not writing a
player, stay with the API above.
| Targets | |
|---|---|
| Plays real media |
macosArm64, iosArm64, iosSimulatorArm64, the Android AAR (minSdk 26, arm64-v8a and x86_64), linuxX64, linuxArm64, mingwX64
|
| Plays media, once you supply the wasm module |
wasmJs. Reading and decoding work, including seeking. Writing files (encode, mux) and filtering are refused by design |
| Builds, nothing has run |
macosX64, iosX64, and the androidNative* targets, which are for Kotlin/Native on Android and are not what a normal Android app uses |
| Placeholder |
js. The code compiles and you can ask it what it supports (nothing), but every media call throws FFmpegError.Unsupported
|
All of these publish at 0.1.0.
Android and iOS play real media on real phones: this is the engine under KitePlayer, which is device-tested on both, down to per-frame GPU timings on a Redmi Note 8. What those platforms do not have is an automated device job in this repository's CI (nobody runs a phone farm here), so their evidence is hand-verified and app-shipped rather than green-on-every-push. Desktop and Windows are the reverse: CI-verified on every push.
One thing to plan around: the JVM jar carries a macOS arm64 native library and only that one, so a JVM app on Linux or Windows resolves the artifact and then gets the typed unavailable placeholder. Per-target detail is in Platform support.
js is a deliberate placeholder: a build that silently did nothing would be worse than one that
tells you it cannot.
wasmJs is not a placeholder. It carries a real playback backend over a generated binding.
FFmpeg cannot be linked into the same binary in a browser, so it arrives as a separate wasm module
you load once before anything else:
KiteFFmpegWeb.load("/kite.mjs") // or attach() a module the page already instantiated
check(FFmpeg.identity.isAcceptable)Calls before that throw KiteFFmpegWeb.NotLoaded. Two things to know before choosing it: the
module is not published with the artifact, so you build it yourself with
:kiteffmpeg:buildFFmpegForWasm* (needs emscripten); and the Kotlin side is tested against a fake
codec module, which proves the binding reads the right fields and proves nothing about the built
artifact. A real browser run against a real module has not been recorded yet.
| Not available | What that means for you |
|---|---|
| A JVM distribution beyond macOS arm64 | Linux and Windows JVM apps get the typed unavailable placeholder, not a codec. |
| Writing or filtering media on the web | Both web targets refuse it. wasmJs can read and decode; it cannot produce a file. js refuses everything. |
| Any GPL FFmpeg | There is no GPL build and no way to swap the embedded one. Shipping GPL binaries would make your whole app GPL-3.0, which is not a choice a library should make for you. |
libx264, libx265, libsvtav1, libopus, libmp3lame
|
No third-party encoder is linked. mpeg4 is the software video baseline and aac the audio one. Decoding is far wider than encoding. |
| Choosing a bitstream filter yourself | These fix up packet formatting when moving between container types. You cannot pick one by hand, but the common ones are built in and FFmpeg applies them for you during a copy. |
| Hardware-accelerated decoding | Hardware encoding works. On virtual machines and CI runners pass allow_sw, where the encoder exists but the physical chip does not. |
https |
The embedded build has no TLS backend. Use http, a local file, or link your own FFmpeg tree. |
| An automated device job in CI | Android and iOS are verified by hand and by a shipping app, not by a phone farm on every push. Desktop and Windows are CI-verified. |
| A frozen API | 0.1.x is pre-1.0, so signatures can still change. What you do get: every public declaration is explicit, and a snapshot of the whole API is checked on every push, so a change fails the build here rather than surprising you at your call site. |
This list was read out of the shipped binary itself, not copied from a build script.
| macOS | Linux / Windows | iOS | Android | |
|---|---|---|---|---|
| Video, software |
mpeg4, mjpeg, png, apng, h263, h263p
|
same | same | same |
| Video, hardware |
h264_videotoolbox, hevc_videotoolbox, prores_videotoolbox
|
none | none |
h264_mediacodec, hevc_mediacodec
|
| Audio |
aac, flac, pcm_s16le, pcm_s24le, pcm_f32le
|
same | same | same |
FFmpeg builds differ, so probe rather than assume: FFmpeg.hasEncoder(...), FFmpeg.hasFilter(...),
FFmpeg.versions and FFmpeg.buildConfiguration all report what is actually linked. Filters marked
deps="gpl" upstream, such as eq, are absent here.
If the linked FFmpeg ever disagrees with the headers this artifact was compiled against, the first call fails with a typed error naming both identities, rather than returning plausible wrong numbers ten frames later. Full codec, container and filter lists are in Platform support.
KiteFFmpeg's own code is Apache-2.0. The embedded FFmpeg is LGPL-2.1-or-later and dav1d is BSD-2-Clause. There is no GPL anywhere, so your application's own licence is untouched and what you link is safe for closed source and for the App Store.
Shipping an app that statically links LGPL code puts three obligations on you: say your app uses
FFmpeg under the LGPL, keep the corresponding FFmpeg source available, and let users relink against
a modified FFmpeg. The first is satisfied by a notice with the licence text, which the JVM jar
carries at META-INF/licenses/kiteffmpeg-ffmpeg/. The second is satisfied by the source tarball
attached to each release. NOTICE states this precisely; Licensing is
the full guide.
The sample is a Kotlin/Native CLI over the whole API. It picks its encoder by probing the linked FFmpeg, so it works against the embedded LGPL build and against your own tree alike.
./gradlew :kiteffmpeg-sample:linkDebugExecutableMacosArm64
KEXE=kiteffmpeg-sample/build/bin/macosArm64/debugExecutable/kiteffmpeg-sample.kexe
$KEXE transcode in.mp4 out.mp4 "scale=1280:720" -acopy # also: info, probe, thumbnail, remux
./gradlew :kiteffmpeg:macosArm64Test # or linuxX64Test / mingwX64Test
scripts/e2e.sh "$KEXE"The C helper layer builds and tests on its own, outside Gradle:
cd native/kitecodec-c
./scripts/build-host.sh plain && ./scripts/run-c-tests.sh plain # also asan, tsan
./scripts/symbol-audit.sh # what the archive needs, exports and keeps private
./scripts/replay-corpus.sh # every committed fuzz seed, under ASan and UBSanWhat each instrument can and cannot prove is written out in native/kitecodec-c/README.md. Every build step is in Getting started; the binding design and timestamp rules are in About KiteFFmpeg.
Apache-2.0. See NOTICE and CHANGELOG.md.
Not affiliated with the FFmpeg project. FFmpeg is a trademark of Fabrice Bellard; this is an independent Kotlin binding that links FFmpeg's LGPL libraries.
Part of the Kite family: KiteCore, KitePDF, KiteImage, KiteQR.