The media engine converts a DASH upstream into HLS that players can open directly. The native engine decrypts and remuxes media inside the Kiln process without starting an external program. FFmpeg is used only as a compatibility fallback.
Choosing an engine
The difference between the two paths comes down to this table. Read it first, then pick a value:
| Capability | Native engine | FFmpeg compatibility path |
|---|---|---|
| Tracks and bitrates | Multi-bitrate ABR, up to 8 video, 16 audio, 16 subtitle tracks | One video plus one audio track, a single bitrate |
| Subtitles | TTML converted to WebVTT tracks | None produced |
| LL-HLS | Supported | Not supported |
| Decryption | cenc and cbcs, multiple KIDs allowed |
A single CENC key |
| Output segments | CMAF | MPEG-TS |
| External dependency | None; everything runs inside the Go process | An ffmpeg binary or container |
[packager].engine takes three values, and they do not overlap:
| Value | Behavior |
|---|---|
auto |
Use the native engine when the source supports it; fall back to FFmpeg when it does not and the failure is safe to fall back from |
native |
Native only. An unsupported source fails outright instead of falling back |
ffmpeg |
Skip native planning entirely and always run FFmpeg |
A channel’s packager field overrides the global [packager].engine, and auto applies when neither is set. The default an image injects through KILN_DEFAULT_PACKAGER_ENGINE only applies when the config omits [packager].engine — an explicit value always wins.
auto does not always fall back
When native planning fails, auto first decides whether that particular failure can be handed to FFmpeg safely. When it cannot, Kiln returns an error rather than switching engines:
- A source with more than one KID cannot fall back. The FFmpeg path accepts a single CENC key, so handing it a multi-KID source would decode the wrong picture. The request fails with
502. - If a channel selects a subtitle track explicitly (
subtitles.modeset topreferoronly), the compatibility engine cannot honor that selection and the request is rejected with400. - If a fallback is needed but FFmpeg is unavailable, the request fails with
503. The Lite and Core images ship without FFmpeg; see Lite, Core & Full.
/readyz moves that boundary earlier: as soon as a DASH channel is pinned to engine = "ffmpeg" while ffmpeg is unavailable, readiness reports 503, so an orchestrator never routes traffic to an instance that is guaranteed to fail.
The native engine
The native engine reads the MPD, plans the tracks, then downloads, decrypts, and repackages segment by segment into CMAF before publishing HLS playlists. The whole chain runs in Go, with no FFmpeg or other external dependency.
What the source has to look like
| Dimension | Supported by the native engine |
|---|---|
| Periods | Single period only |
| Segment addressing | SegmentTemplate with SegmentTimeline, SegmentTemplate with duration, SegmentList |
| Video codecs | avc1, avc3, hvc1, hev1 |
| Audio codecs | mp4a |
| Subtitle codecs | stpp (TTML) |
| Encryption | cenc and cbcs; clear sources also run natively |
| Track counts | Up to 8 video, 16 audio, and 16 subtitle tracks |
avc3 and hev1 require their parameter sets to live in avcC or hvcC; a track that carries them in-band only is treated as unsupported. The ABR ladder deduplicates candidates by resolution and frame rate, keeps the highest bitrate in each group, and trims to the top 8 rungs.
How a publication is built
Plan
Fetch and normalize the MPD, then derive the ABR ladder, the audio renditions, and the subtitle tracks from prefer_height and the channel’s track selection.
Verify
Read each track’s init segment, cross-check the manifest’s default_KID against the KID in tenc, and confirm the key catalog has a matching key.
Repackage
Download each segment, decrypt it in memory, and rewrite it as a CMAF segment — sliced into parts when LL-HLS is on.
Publish
Write the multivariant playlist and one media playlist per track, carrying EXT-X-MAP and EXT-X-PROGRAM-DATE-TIME.
Reasons a source is rejected
When planning or verification fails, the logs and the channel’s probe result carry a reason code:
| Reason | Meaning | Falls back under auto |
|---|---|---|
multi_period |
The manifest declares more than one period | Yes |
no_video_representation |
No usable video representation | Yes |
no_audio_representation |
No usable audio representation | Yes |
addressing_unsupported |
The segment addressing mode is not supported | Yes |
manifest_codec_unsupported |
The codec or track shape is outside the native set | Yes |
manifest_kid_conflicts_with_tenc |
The manifest’s default_KID disagrees with the init segment’s tenc |
Yes |
missing_key_for_kid |
The key catalog has no key for that KID | Yes |
multi_kid_cannot_fall_back |
The source uses several KIDs, which single-key FFmpeg would decode incorrectly | No |
native_start_failed |
Any other error during native start-up | Yes |
Malformed init segments produce a finer set of reasons — not_fragmented_mp4, multi_track_init, encryption_scheme_unsupported, missing_track_kid, inband_parameter_sets, malformed_media — each meaning exactly what its name says. See Troubleshooting for how to read them.
Key file
Every DASH channel shares one global key catalog, pointed at by [packager].keys_file:
# One pair per line; lines starting with # are comments
0123456789abcdef0123456789abcdef:fedcba9876543210fedcba9876543210
01234567-89ab-cdef-0123-456789abcdef:00112233445566778899aabbccddeeffThe parser enforces the format strictly:
- Each line is one
kid:keypair. Blank lines and lines starting with#are ignored. - The KID is 32 hex characters and may contain dashes; the key is 32 hex characters and may not.
- A repeated KID with a different key is an error; a repeated KID with the same key is skipped as a duplicate.
- A file with no valid entry at all is an error.
Relative paths resolve against the directory holding kiln.toml, not the process working directory — which is why keeping the config and the key file side by side still works under the Windows service.
Keys never appear in any admin API response, and the per-channel keys field has been removed, so there is exactly one place a key can come from.
LL-HLS
With [packager].ll_hls enabled, the native engine publishes low-latency HLS: playlists move to version 9, gain EXT-X-PART-INF and EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES, and three mechanisms come online together.
CMAF parts. Each segment is sliced into parts advertised through EXT-X-PART, with independently decodable parts marked INDEPENDENT=YES. The part currently being produced is announced through EXT-X-PRELOAD-HINT. Part duration comes from part_target_ms, which accepts 100 to 5000 and defaults to 500.
Delta playlists. A player that sends _HLS_skip=YES or _HLS_skip=v2 receives only the tail of the playlist, with the omitted segments counted by EXT-X-SKIP:SKIPPED-SEGMENTS. The skip boundary is six times the target duration, which is exactly what CAN-SKIP-UNTIL advertises.
Blocking reload. A request carrying _HLS_msn, optionally with _HLS_part, is held open until that media sequence number or part is ready. _HLS_part requires _HLS_msn, and a sequence number too far ahead is rejected instead of waiting forever. PART-HOLD-BACK is advertised as twice part_target_ms.
The related playlist parameters:
| Key | Default | Purpose |
|---|---|---|
playlist_size |
8 |
Segments kept in a media playlist |
part_target_ms |
500 |
Target duration of a CMAF part, in milliseconds |
start_segments |
3 |
Segments published on cold start — the tail of the window for live, the head for on-demand |
prefetch_segments |
3 |
Segments prepared concurrently in the pipeline |
With ll_hls off, playlists drop back to version 7, no part tags are written, and part_target_ms stops mattering.
Memory versus latency
Peak memory for the native engine is governed mostly by one key: inflight_bytes. It is a byte budget for segment data held across every channel at once, counted in bytes rather than segments because a single 4K segment can be tens of megabytes — a segment-count limit would make memory a function of the upstream bitrate.
What that budget buys is cold-start speed, and nothing else: in steady state each track pulls one segment per refresh and never approaches the limit. The example config records measurements taken with a 4K channel and a 1080p channel running at once, both with several audio renditions:
inflight_bytes |
Resident memory | First 4K playlist |
|---|---|---|
96 MiB (default, 100663296) |
140 to 170 MB | about 8.6 s |
32 MiB (33554432) |
106 to 112 MB | about 11 s |
Lower it when resident memory matters more than how fast a 4K channel starts.
The remaining keys each guard one boundary:
max_segment_bytes, default33554432(32 MiB), caps a single segment. Together withinflight_bytesit also derives the number of download and decrypt slots, so lowering it tightens concurrency at the same time.primary_track_hold_sec, default12, is how far in media seconds audio may run ahead of video. It is not an A/V sync knob: it stops audio from sliding its playlist window past what a player stalled on video still needs.stall_timeout_sec, default180. When the manifest keeps updating but nothing ever reaches the playlist, the fault is on our side, so the publication fails and a restart re-plans from scratch. An unreachable upstream is not this case and keeps retrying. Set-1to disable.grace_sec, default30, is how long a segment stays fetchable after it leaves the playlist, covering players that are still reading an older playlist.
The FFmpeg compatibility path
This path is reached only when the native engine cannot handle the source and the failure is safe to fall back from.
[ffmpeg].mode decides how FFmpeg is executed:
nativeruns the local[ffmpeg].binary(defaultffmpeg, resolved fromPATH).dockerhas Kiln issuedocker run --rmitself against[ffmpeg].docker_image, bind-mounting the work directory, running as a non-root user, and passing outbound proxy environment variables through. No wrapper script is needed.
The rest of the settings:
| Key | Default | Purpose |
|---|---|---|
hls_time |
2 |
Target duration of an output segment, in seconds |
hls_list_size |
8 |
Segments kept in the output playlist; the default drops to 4 when low_latency is on |
prefer_height |
unset | Resolution ceiling; a channel’s prefer_height wins |
low_latency |
false |
Picks the default for hls_list_size: 4 when on, 8 when off. An explicit hls_list_size always wins |
max_starts |
1 |
Concurrent FFmpeg launches. It does not cover the readiness wait, so one slow source cannot block every other channel’s cold start |
log_level |
error |
Log level passed to FFmpeg |
The compatibility path is noticeably narrower than the native one: it picks one video and one audio rendition, stream-copies with -c copy, emits MPEG-TS segments, and uses a single CENC key. There are no subtitles, no ABR ladder, and no LL-HLS parts. Treat it as a way to keep a channel playable, not as an equivalent implementation.
Subtitles and timed metadata
Subtitles. The native engine consumes DASH stpp tracks, that is, fragmented TTML. Each segment is parsed into cues, clipped to that segment’s time window, and emitted as a WebVTT segment carrying X-TIMESTAMP-MAP, so players see an ordinary HLS subtitle rendition. Language tags are normalized before they are written into EXT-X-MEDIA. Text tracks that are not stpp, or whose addressing is unsupported, are skipped without affecting audio or video; a channel carries at most 16 subtitle tracks. Setting subtitles.mode to off disables subtitles entirely, and prefer or only can only be honored by the native engine.
Timed metadata. emsg boxes inside segments are parsed in both version 0 and version 1, with version 0’s relative time resolved against the segment’s presentation time. Schemes are classified as SCTE-35, ID3, or plain emsg. SCTE-35 splice information is rewritten as an HLS EXT-X-DATERANGE: CLASS is fixed to com.apple.hls.scte35, and SCTE35-OUT, SCTE35-IN, or SCTE35-CMD is written according to direction, alongside PLANNED-DURATION, DURATION, and END-DATE. Observations sharing an event ID are merged across refreshes, so an out seen before a matching in gets its end time filled in automatically. ID3 and other schemes are recognized but are not currently written as playlist tags.