DEV Community

Javid Jamae
Javid Jamae

Posted on • Originally published at ffmpeg-micro.com

How to Use FFmpeg with Kotlin (No Installation Required)

Originally published at ffmpeg-micro.com

You need video processing in your Kotlin app. Maybe you're building a Spring Boot backend that transcodes user uploads, an Android app that generates social clips, or a Ktor service that watermarks content before delivery. You search "ffmpeg kotlin" and find scattered examples, most from 2019.

FFmpeg handles the video processing. But running it from Kotlin means picking between ProcessBuilder (which works but requires FFmpeg on every machine), a JVM wrapper library, or a cloud API that skips the install entirely. This post covers all three with working Kotlin code.

Running FFmpeg from Kotlin with ProcessBuilder

Kotlin runs on the JVM, so you get Java's ProcessBuilder for free. Install FFmpeg on the machine, then shell out to it:

val process = ProcessBuilder(
    "ffmpeg",
    "-i", "input.mp4",
    "-c:v", "libx264",
    "-crf", "23",
    "-preset", "medium",
    "-c:a", "aac",
    "-b:a", "128k",
    "output.mp4"
).redirectErrorStream(true).start()

process.inputStream.bufferedReader().forEachLine { println(it) }

val exitCode = process.waitFor()
check(exitCode == 0) { "FFmpeg failed with exit code $exitCode" }
Enter fullscreen mode Exit fullscreen mode

This is the simplest path. But you own the FFmpeg install on every server, every CI runner, every Docker image. On Docker, FFmpeg adds 80-200MB. On serverless (Lambda, Cloud Functions), you can't install system binaries at all and the 250MB package limit is tight.

Using Jaffree for a Fluent API

Jaffree is the best JVM wrapper for FFmpeg. It works perfectly from Kotlin and gives you a builder API instead of string arrays:

// build.gradle.kts
dependencies {
    implementation("com.github.kokorin.jaffree:jaffree:2023.09.10")
}
Enter fullscreen mode Exit fullscreen mode
import com.github.kokorin.jaffree.ffmpeg.FFmpeg
import com.github.kokorin.jaffree.ffmpeg.UrlInput
import com.github.kokorin.jaffree.ffmpeg.UrlOutput

FFmpeg.atPath()
    .addInput(UrlInput.fromUrl("input.mp4"))
    .addOutput(
        UrlOutput.toUrl("output.mp4")
            .addArguments("-c:v", "libx264")
            .addArguments("-crf", "23")
            .addArguments("-preset", "medium")
            .addArguments("-c:a", "aac")
            .addArguments("-b:a", "128k")
    )
    .execute()
Enter fullscreen mode Exit fullscreen mode

Jaffree also supports progress tracking, which is useful for showing users a progress bar:

FFmpeg.atPath()
    .addInput(UrlInput.fromUrl("input.mp4"))
    .addOutput(
        UrlOutput.toUrl("output.mp4")
            .addArguments("-c:v", "libx264")
            .addArguments("-crf", "23")
    )
    .setProgressListener { progress ->
        println("Frame: ${progress.frame}, Time: ${progress.timeMark}")
    }
    .execute()
Enter fullscreen mode Exit fullscreen mode

Better than raw ProcessBuilder. But Jaffree still needs FFmpeg on the host. It's a wrapper, not a replacement. You still manage the binary across environments.

Processing Video via Cloud API (No FFmpeg Install)

If you don't want FFmpeg on your machines, offload the work to a cloud API. FFmpeg Micro gives you full FFmpeg capabilities through HTTP. Here's a minimal example using OkHttp, the most common HTTP client in the Kotlin ecosystem:

// build.gradle.kts
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.json:json:20240303")
}
Enter fullscreen mode Exit fullscreen mode
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.json.JSONArray

val client = OkHttpClient()
val apiKey = System.getenv("FFMPEG_MICRO_API_KEY")

val body = JSONObject().apply {
    put("inputs", JSONArray().put(JSONObject().put("url", "https://example.com/input.mp4")))
    put("outputFormat", "mp4")
    put("preset", JSONObject().apply {
        put("quality", "high")
        put("resolution", "1080p")
    })
}.toString()

val request = Request.Builder()
    .url("https://api.ffmpeg-micro.com/v1/transcodes")
    .header("Authorization", "Bearer $apiKey")
    .header("Content-Type", "application/json")
    .post(body.toRequestBody("application/json".toMediaType()))
    .build()

val response = client.newCall(request).execute()
val result = JSONObject(response.body!!.string())
val jobId = result.getString("id")
println("Job created: $jobId")
Enter fullscreen mode Exit fullscreen mode

No binary to install. No Docker image bloat. No codec management. Send a video URL, pick your settings, get results back.

For advanced operations, pass raw FFmpeg options instead of presets:

val advancedBody = JSONObject().apply {
    put("inputs", JSONArray().put(JSONObject().put("url", "https://example.com/input.mp4")))
    put("outputFormat", "webm")
    put("options", JSONArray().apply {
        put(JSONObject().put("option", "-c:v").put("argument", "libvpx-vp9"))
        put(JSONObject().put("option", "-crf").put("argument", "30"))
        put(JSONObject().put("option", "-b:v").put("argument", "0"))
    })
}.toString()
Enter fullscreen mode Exit fullscreen mode

Polling for Completion

Transcode jobs are async. Poll until the job finishes:

var status = "queued"
while (status == "queued" || status == "processing") {
    Thread.sleep(2000)
    val statusRequest = Request.Builder()
        .url("https://api.ffmpeg-micro.com/v1/transcodes/$jobId")
        .header("Authorization", "Bearer $apiKey")
        .get()
        .build()
    val statusResponse = client.newCall(statusRequest).execute()
    status = JSONObject(statusResponse.body!!.string()).getString("status")
    println("Status: $status")
}
Enter fullscreen mode Exit fullscreen mode

Once the status is completed, grab the download URL:

val downloadRequest = Request.Builder()
    .url("https://api.ffmpeg-micro.com/v1/transcodes/$jobId/download")
    .header("Authorization", "Bearer $apiKey")
    .get()
    .build()

val downloadResponse = client.newCall(downloadRequest).execute()
val downloadUrl = JSONObject(downloadResponse.body!!.string()).getString("url")
// Signed URL valid for 10 minutes
Enter fullscreen mode Exit fullscreen mode

Using Ktor Client Instead

If you're already using Ktor, use its built-in HTTP client instead of adding OkHttp:

// build.gradle.kts
dependencies {
    implementation("io.ktor:ktor-client-core:2.3.12")
    implementation("io.ktor:ktor-client-cio:2.3.12")
    implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
}
Enter fullscreen mode Exit fullscreen mode
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*

val client = HttpClient(CIO)

val response = client.post("https://api.ffmpeg-micro.com/v1/transcodes") {
    header("Authorization", "Bearer $apiKey")
    contentType(ContentType.Application.Json)
    setBody("""
        {
            "inputs": [{"url": "https://example.com/input.mp4"}],
            "outputFormat": "mp4",
            "preset": {"quality": "high", "resolution": "1080p"}
        }
    """.trimIndent())
}

println(response.bodyAsText())
Enter fullscreen mode Exit fullscreen mode

Ktor's client is lighter than OkHttp and plays well with coroutines if your backend is already async.

CLI vs. API: Side-by-Side

Same operation (converting MP4 to 720p WebM) done both ways:

FFmpeg CLI (requires local install):

ffmpeg -i input.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 -vf scale=-2:720 output.webm
Enter fullscreen mode Exit fullscreen mode

FFmpeg Micro API (no install):

val body = JSONObject().apply {
    put("inputs", JSONArray().put(JSONObject().put("url", "https://example.com/input.mp4")))
    put("outputFormat", "webm")
    put("options", JSONArray().apply {
        put(JSONObject().put("option", "-c:v").put("argument", "libvpx-vp9"))
        put(JSONObject().put("option", "-crf").put("argument", "30"))
        put(JSONObject().put("option", "-b:v").put("argument", "0"))
        put(JSONObject().put("option", "-vf").put("argument", "scale=-2:720"))
    })
}.toString()
Enter fullscreen mode Exit fullscreen mode

The CLI version needs FFmpeg installed. The API version runs from any Kotlin app with an HTTP client.

Common Pitfalls

ProcessBuilder path issues on macOS. Kotlin scripts and Gradle tasks don't always inherit your shell PATH. If FFmpeg isn't found, pass the full path (/usr/local/bin/ffmpeg or /opt/homebrew/bin/ffmpeg on Apple Silicon).

Blocking the main thread in Android. Never call ProcessBuilder or make HTTP requests on the main thread. Wrap everything in a coroutine with Dispatchers.IO:

withContext(Dispatchers.IO) {
    val response = client.newCall(request).execute()
    // handle response
}
Enter fullscreen mode Exit fullscreen mode

Jaffree version compatibility. Some older Jaffree versions have issues with FFmpeg 6.x. Use 2023.09.10 or newer.

Large file uploads. For files over 100MB, use FFmpeg Micro's presigned upload flow instead of passing a public URL. Generate a presigned URL, PUT the file directly to cloud storage, confirm the upload, then reference the GCS URL in your transcode request.

FAQ

Can I use FFmpeg on Android directly? Not easily. Android doesn't include FFmpeg, and cross-compiling it for ARM is painful. Mobile FFmpeg (now FFmpeg Kit) works but adds 20-50MB to your APK. A cloud API avoids all of this.

Does Kotlin Multiplatform work with FFmpeg? On JVM targets, yes (ProcessBuilder or Jaffree). On Native or JS targets, no. A cloud API works from any Kotlin target since it's just HTTP.

What's the cost of using a cloud API vs self-hosting? FFmpeg Micro has a free tier for testing and small workloads. For production, pricing is per-minute of video processed. Self-hosting costs server time, maintenance, and your attention when codecs break after an OS update.

Is the API fast enough for real-time processing? FFmpeg Micro is designed for batch and async workflows, not real-time streaming. Jobs typically complete in seconds for short videos. For live streaming, you need a different tool.

How do I handle errors from the API? Check the HTTP status code. 400 means bad request (wrong parameters). 402 means you've hit your quota. 401 means your API key is invalid or expired. The response body always includes an error message.

Last verified: June 2026. Code examples tested against FFmpeg 7.x and FFmpeg Micro API v1.

Top comments (0)