Remuxing¶
Rewrite a media file into a different container without touching the encoded streams. Remuxing moves the same packets from one wrapper to another (mp4, mkv, mov), so it runs in seconds, produces bit-exact output, and never decodes or re-encodes a single frame.
Overview¶
Remuxer.remux(input, output) opens the input via libavformat, reads its packets, and writes them straight into a new container chosen from the output file's extension. The encoded bitstream is copied verbatim: the pixels and samples in the output are the same bytes that were in the input. Only the container framing and the packet timestamps (rescaled onto the new container's time-base) change.
import io.github.yuroyami.kiteffmpeg.Remuxer
// mp4 -> mkv, lossless, runs in seconds
Remuxer.remux("input.mp4", "output.mkv")
The full signature:
suspend fun remux(
input: String,
output: String,
streamIndices: List<Int>? = null,
startMicros: Long = 0,
endMicros: Long = Long.MAX_VALUE,
metadata: Map<String, String> = emptyMap(),
onProgress: ((packetsWritten: Long) -> Unit)? = null,
): Unit
| Parameter | Purpose |
|---|---|
input |
Path to the source media file. |
output |
Path to write. The container is inferred from the extension (.mp4, .mkv, .mov). |
streamIndices |
Which input streams to carry over. null copies every stream. |
startMicros |
Start of the copied range, in microseconds. Snapped back to the preceding keyframe. |
endMicros |
End of the copied range, in microseconds. Defaults to no upper bound. |
metadata |
Container-level metadata to set on the output, for example mapOf("title" to "Episode 1"). |
onProgress |
Optional callback invoked with the running count of packets written. |
remux is a suspend function, so call it from a coroutine. It uses constant memory regardless of how long the input runs, because it streams packets one at a time rather than buffering the file.
Note
The output container is chosen entirely from the output path's extension. To rewrite an mp4 as a Matroska file, write to output.mkv. To repackage into a QuickTime movie, write to output.mov.
When to remux versus transcode¶
Remuxing is the right tool when the streams you already have are acceptable and only the container needs to change. Transcoding is required when the encoded data itself must change.
| You want to... | Use |
|---|---|
| Change the container (mp4 to mkv, mov to mp4) | Remuxer.remux |
| Drop a stream or keep only a subset | Remuxer.remux with streamIndices |
| Cut a clip without re-encoding (keyframe-aligned) | Remuxer.remux with startMicros / endMicros |
| Edit container metadata only | Remuxer.remux with metadata |
| Resize, change codec, change bitrate, or apply a filter | Transcoder.transcode |
| Cut a frame-exact clip (any start time, not just keyframes) | Transcoder.transcode with startMicros / endMicros |
The deciding question is whether the encoded bitstream has to be rebuilt. Remuxing keeps every packet exactly as it was. Transcoding decodes, processes, and re-encodes, which costs time and CPU and is lossy for lossy codecs. If you do not need to touch the pixels or samples, remux.
Need a frame-exact cut?
Remux trim snaps the start to the nearest preceding keyframe, because copied packets cannot begin mid-GOP. If you need the clip to start on an exact, arbitrary timestamp, use Transcoder.transcode with the same startMicros / endMicros. It re-encodes from the start point, so the cut is frame-exact, at the cost of decode and encode.
Lossless container rewrite¶
The simplest call copies every stream into a new container. This is the fastest operation in the library: no decode, no encode, just packet copy and timestamp rescale.
// Repackage an mp4 as Matroska
Remuxer.remux("input.mp4", "output.mkv")
// Or the other way
Remuxer.remux("input.mkv", "output.mp4")
// Into a QuickTime movie
Remuxer.remux("input.mov", "output.mp4")
The output is bit-exact for the media data: the H.264, HEVC, AAC, or Opus packets in output.mkv are byte-identical to those in input.mp4. Quality is unchanged because nothing was re-encoded.
Container compatibility
Not every codec fits in every container. A stream copied from a permissive container into a stricter one can fail if the target format does not accept that codec. When the muxer rejects a stream, remux raises an FFmpegException. See Errors below.
Watching progress¶
Pass onProgress to observe the packet count as the rewrite runs. The callback receives the running total of packets written.
Remuxer.remux(
input = "input.mp4",
output = "output.mkv",
onProgress = { packets -> println("wrote $packets packets") },
)
Because remuxing copies packets rather than encoding frames, progress is reported in packets written, not frames. For a typical file this completes too quickly to need a progress bar, but the callback is there for large inputs.
Selecting streams¶
By default every stream is carried over. Pass streamIndices to keep only the streams you want, using the indices from MediaSource.streams.
import io.github.yuroyami.kiteffmpeg.MediaSource
MediaSource.open("input.mkv").use { src ->
val video = src.primaryVideo ?: error("no video stream")
val audio = src.primaryAudio ?: error("no audio stream")
// Keep only the primary video and audio, drop everything else
Remuxer.remux(
input = "input.mkv",
output = "output.mp4",
streamIndices = listOf(video.index, audio.index),
)
}
Every entry in streamIndices is a StreamInfo.index value from the source. Streams you omit are not written to the output. This is how you drop a second audio track, a subtitle stream, or a data stream that the target container will not accept.
Keyframe-snapped trim¶
Pass startMicros and endMicros to copy only a time range. Because remuxing copies encoded packets and a video packet can only be decoded from a keyframe, the start is snapped back to the nearest preceding keyframe.
// Copy roughly 60s to 120s, no re-encode
Remuxer.remux(
input = "input.mp4",
output = "clip.mp4",
startMicros = 60_000_000,
endMicros = 120_000_000,
)
The result keeps the start on a keyframe boundary, so the clip may begin slightly earlier than the exact startMicros you asked for. The output timeline is preserved relative to the copied packets. This is the copy-mode equivalent of ffmpeg -ss ... -to ... -c copy.
Start lands on a keyframe, not your exact timestamp
With stream copy there is no way to begin in the middle of a GOP, so startMicros is snapped to the keyframe at or before it. The first frames of the clip are the frames between that keyframe and your requested start. If you need the clip to start on the exact frame, transcode the range instead.
endMicros defaults to Long.MAX_VALUE, meaning "to the end of the file". To copy from a point to the end, set only startMicros:
Setting metadata¶
The metadata map writes container-level metadata onto the output. Keys are standard container metadata names such as title, artist, or comment.
Remuxer.remux(
input = "input.mp4",
output = "output.mkv",
metadata = mapOf(
"title" to "Episode 1",
"comment" to "Remuxed with KiteFFmpeg",
),
)
You can combine metadata with trimming and stream selection in a single call.
Worked example: trim, select, and tag in one pass¶
import io.github.yuroyami.kiteffmpeg.MediaSource
import io.github.yuroyami.kiteffmpeg.Remuxer
MediaSource.open("input.mkv").use { src ->
val video = src.primaryVideo ?: error("no video stream")
val audio = src.primaryAudio ?: error("no audio stream")
Remuxer.remux(
input = "input.mkv",
output = "highlight.mp4",
streamIndices = listOf(video.index, audio.index), // drop extra tracks
startMicros = 90_000_000, // ~90s, keyframe-snapped
endMicros = 120_000_000, // ~120s
metadata = mapOf("title" to "Highlight"),
onProgress = { packets -> println("$packets packets") },
)
}
This opens the source once, keeps only the primary video and audio, copies the keyframe-aligned range from 90s to 120s into an mp4, tags it, and reports progress. No frame is decoded or re-encoded.
Errors¶
Failures surface as FFmpegException, carrying an FFmpegError:
- Semantic subclasses (
FFmpegError.FileNotFound,FFmpegError.MuxerNotFound,FFmpegError.InvalidData, and more) classify the commonAVERROR_*codes. Two examples are a codec that cannot be muxed into the chosen container, and an input that cannot be opened. FFmpegError.AvErrorcarries any code without a dedicated category.FFmpegError.Internalsignals a library-side invariant failure.
import io.github.yuroyami.kiteffmpeg.FFmpegException
try {
Remuxer.remux("input.mp4", "output.mov")
} catch (e: FFmpegException) {
println("remux failed: code=${e.code} ${e.error}")
}
A common cause is a codec the target container does not accept. If remux rejects a stream, either drop it with streamIndices, choose a container that accepts it, or transcode the stream to a compatible codec.
See also¶
- Transcoding: re-encode, resize, filter, and frame-exact trim.
- Decoding & frames:
MediaSource, stream inspection, and stream indices. - Encoding & muxing:
MediaSinkand per-stream copy withaddCopyStream. - Recipes: copy-paste patterns for common tasks.
- API reference: full signatures for every type.