1 September 2026

I froze the API on my video library. Then I found seven holes in it.

A year building a declarative video framework for Apple platforms, and the week that taught me the most came after 1.0.

kadr 1.1.0 Swift 6 · iOS 17+ Apache 2.0 · no dependencies

Last week I tagged 1.0 on a Swift video library I have been building for a year, and promised not to break its API. Six days later I had shipped 1.1, because building one more package against it turned up seven places where the library could hand you a value and then give you no way to get it back.

None of them were bugs. Every test passed. That is the part worth writing about.

Why the library exists

AVFoundation can do almost anything to video and expresses almost none of it readably. Take the simplest possible edit — play clip A, dissolve into clip B:

let composition = AVMutableComposition()
let trackA = composition.addMutableTrack(withMediaType: .video, ...)!
let trackB = composition.addMutableTrack(withMediaType: .video, ...)!

// A crossfade needs *two* tracks: one track cannot show two frames at once,
// so the clips overlap in time and that overlap is the transition.
let bStart = CMTimeSubtract(durationA, transition)
try trackA.insertTimeRange(..., of: sourceA, at: .zero)
try trackB.insertTimeRange(..., of: sourceB, at: bStart)

let fading = AVMutableVideoCompositionLayerInstruction(assetTrack: trackA)
fading.setOpacityRamp(fromStartOpacity: 1, toEndOpacity: 0, timeRange: overlap)

videoComposition.instructions = [
    instruction(CMTimeRange(start: .zero, duration: bStart), [layerA]),
    instruction(overlap, [fading, layerB]),      // order is z-order
    instruction(..., [layerB]),
]

That is 48 lines by the time it compiles. And it renders phone video sideways, because a composition track does not inherit its source's preferred transform — so you add per-layer transforms, then scaling for mismatched resolutions, then an AVMutableAudioMix with volume ramps because otherwise the sound hard-cuts under a picture that dissolves.

48lines, ignoring orientation, scale and audio
~120once those work
9to describe the same edit

Almost none of those 120 lines are about your video. They are bookkeeping — five objects that all have to agree about time, with nothing checking that they do.

let url = try await Video {
    VideoClip(url: a)
    Transition.dissolve(duration: 0.5)
    VideoClip(url: b)
}
.preset(.reelsAndShorts)
.export(to: output)

That is kadr: a result-builder DSL over AVFoundation, async/await throughout, no third-party dependencies. Multi-track timelines, transitions, overlays, filters with keyframe animation, custom per-frame Core Image compositors, chroma key, LUTs, time-anchored audio with crossfades and ducking, and export targeted at a bitrate or a file size — which AVAssetExportSession cannot express at all, its presets being the entire vocabulary.

Then I wrote the seventh package

An editor has to save projects. kadr's types cannot be Codable — they hold closures and platform images — so every consumer hand-writes a mirror of the DSL. I had already done it once in the reference app, badly, so I built it properly as a package.

Writing it meant using kadr the way a stranger would: from outside, with only the public surface. Within an afternoon:

Six hundred and fifty-eight tests, and not one could see any of it.

Why not

Because they were written from inside, by the person who wrote the code, testing the paths he had already thought of. Every audio test built its tracks as literals — so the array path was never exercised, because it never occurred to anyone to exercise it.

A test suite written from inside a package tests the paths the author took. The holes are exactly where the author did not go.

The fix is one line, and it is not a new test:

// Not @testable — this asserts the surface a client sees.
import Kadr

@testable import is excellent for testing internals and actively misleading for testing an API, because it hands your tests precisely the privileges your users lack. You never notice the door is locked when the key is in your pocket.

The bug no round-trip test can find

The persistence layer needed that mirror of the DSL, and hand-written mirrors have one characteristic failure:

Add a field upstream, forget the mirror, and nothing fails. You encode, decode, compare — and a field missing from both sides of the comparison compares equal. It was never written, so it is never read, so nothing disagrees. Green, and the data is gone.

I knew this. I was writing the documentation comment about it. My first draft dropped six of the composition's ten fields, including one that was an entire release's headline feature, already shipping and already being silently discarded.

Knowing about a class of bug does not protect you from it. Only a mechanism does — so the mirror is now checked by shape rather than by value:

let actual = Set(Mirror(reflecting: video).children.compactMap(\.label))
let known = encoded.union(deliberatelyNotEncoded)
#expect(actual.subtracting(known).isEmpty)

Add a field upstream and the test fails with the field's name in the message. It caught all six on its first run, and it asserts nothing about behaviour at all.

The one I actually shipped

The format refuses, by default, to save anything it cannot represent — better a loud error than a project that reopens subtly wrong. Wiring it into the app I set encoding to strict, reasoning that the editor could not author any of the refusable things.

It could. The overlay animation picker sets one. So adding a fade to a text overlay made autosave throw, the editor said "Couldn't save", and every edit after that lived only in memory. That shipped.

The reasoning was sound and the conclusion was wrong, and the gap between them was that I had asserted it instead of testing it. The repair is four lines that check the claim rather than a case:

#expect(
    KadrCoding.lossyContent(in: store.project.makeVideo()).isEmpty,
    "the editor can author something the format refuses to save"
)

Any future feature that violates it fails immediately — including features nobody has thought of yet.

What 1.0 actually means here

All seven holes are closed, additively, without breaking the promise. Nothing public is removed, renamed or redefined inside 1.x; minors add; a deprecation runs at least one minor with a named replacement before a major can remove it.

What the promise excludes, stated plainly: internal symbols, the exact bytes an export produces — encoders change underneath everyone — and performance, which is tracked as a regression baseline in the repository rather than promised. Measuring it turned up that keyframes are free: 120 of them cost 0.538 s against a plain single-track 0.539 s, inside the noise. Optimising keyframe evaluation would have been optimising nothing.

And what it does not do

It composes and exports. It is not an editor and it renders nothing on screen. There is no timeline UI, no gesture handling, no project format, no opinion about your architecture. Five companion packages cover those separately — SwiftUI components, persistence, audio, captions, Photos — and a full editor is built on all of them, which is how the holes above were found in the first place.

.package(url: "https://github.com/SteliyanH/kadr.git", from: "1.0.0")

Apache 2.0. Swift 6, strict concurrency, iOS 17 and up. 658 tests and zero dependencies.

The design decision I would most like to be argued with about is that everything is an immutable value type, so an edit rebuilds the composition rather than mutating it. Undo is free, diffing is free, and a 200-clip timeline rebuilds an array on every keystroke. It has never been a problem in practice — but "not a problem for me" is not "fine", and the API is frozen now.