SwiftUI for video. Compose, transform, export — in Swift you actually want to write.
v1.0 — the API is frozen. Nothing public is removed, renamed or redefined inside
1.x. See API stability.
A modern, declarative Swift library for video composition on Apple platforms. Build videos using a result-builder DSL with async/await throughout. Multi-track timelines, transitions, overlays, filters with keyframe animation, custom per-frame compositors, time-anchored audio with crossfades — all on top of AVFoundation, no third-party dependencies.
API documentation → · built and hosted by the Swift Package Index for every release.
Companion packages. Kadr is the engine. Five packages consume its public surface for specific jobs — pull in only what you need; none are required for composition or export.
Package Purpose kadr-uiSwiftUI components — VideoPreview,ThumbnailStrip, multi-laneTimelineView(selection / reorder / trim / scrub / audio waveforms),OverlayHostwith gesture-routedLayerIDhit-testing,InspectorPanelwith filter authoring,TransitionPicker,ClipSplitter,KeyframeEditor.kadr-persistenceSave a composition to a file and open it again — refusing, rather than silently dropping, whatever a file cannot hold. Content-addressed image storage; a completeness guard that fails when kadr grows a field. kadr-audioMusic-library resolution, in-app voiceover recording, audio session policy, and ITU-R BS.1770-4 (LUFS) loudness measurement. kadr-captionsCaption parsing and authoring for SRT, VTT, iTT, ASS and SSA, plus a styled-VTT bridge onto TextOverlay+textAnimationfor burned-in animated captions.kadr-photosPhotos library integration — resolves video / image / Live Photo PHAssets into kadr clip types, wrapsPHPickerViewControllerfor SwiftUI, bridges assets toImageOverlay/StickerOverlay.And a reference application: Kadr Studio, a short-form vertical video editor built on all six.
The simplest possible composition — slideshow with background music:
import Kadr
let url = try await Video {
ImageClip(heroImage, duration: 5.0)
}
.audio(url: musicURL)
.export(to: outputURL)A more representative v0.8 composition — Ken Burns zoom-pan on a still, animated title reveal, picture-in-picture cutaway, and a music swap with a 1s crossfade:
let url = try await Video {
ImageClip(heroPhoto, duration: 5.0)
.transform(.identity, animation: .keyframes([
.at(0.0, value: Transform(scale: 1.0)),
.at(5.0, value: Transform(scale: 1.3, center: .normalized(x: 0.6, y: 0.4))),
], timing: .easeInOut))
Transition.dissolve(duration: 0.5)
VideoClip(url: clipURL).trimmed(to: 0...10)
// PiP cutaway pinned at t=6s, 40% scale in the top-right
VideoClip(url: cutawayURL).trimmed(to: 0...3)
.at(time: 6.0)
.transform(Transform(center: .topRight, scale: 0.4, anchor: .topRight))
}
.overlay(
TextOverlay("MY MOVIE", style: TextStyle(fontSize: 80, alignment: .center, weight: .bold))
.position(.center)
.visible(during: 0.0...2.0)
.animation(.fadeIn(duration: 1.0))
)
.audio {
AudioTrack(url: musicAURL).at(time: 0).duration(8.0).crossfade(1.0)
AudioTrack(url: musicBURL).at(time: 7.0) // 1s overlap fades A → B
}
.preset(.reelsAndShorts)
.export(to: outputURL)FFmpegKit retired in January 2025. Pixel SDK sunset in February 2025. AVFoundation is powerful but verbose. The Swift video ecosystem needs a modern, native, declarative library.
7 imperative functions become 3 DSL primitives + modifiers:
| Before (imperative) | After (Kadr) |
|---|---|
generate(.single, image, audio) |
Video { ImageClip(img) }.audio(url:).export(to:) |
mergeMovies(videoURLs:) |
Video { urls.map { VideoClip(url: $0) } }.export(to:) |
reverseVideo(fromVideo:) |
Video { VideoClip(url:).reversed() }.export(to:) |
splitVideo(at:) |
Video { VideoClip(url:).trimmed(to: 5...20) }.export(to:) |
mergeVideoWithAudio(...) |
Video { VideoClip(url:).muted() }.audio(url:).export(to:) |
| Kadr | AVFoundation (raw) | VideoLab | FFmpegKit | |
|---|---|---|---|---|
| API style | Declarative DSL | Imperative | Layer-based | CLI wrapper |
| Swift concurrency | async/await native | Callbacks | No | No |
| Swift 6 / Sendable | Full strict concurrency | Partial | No | No |
| Maintained (2026) | Active | Apple (low-level) | Inactive | Retired (Jan 2025) |
| Dependencies | None (AVFoundation only) | N/A | None | FFmpeg binary |
| Learning curve | Minutes | Hours | Hours | Moderate |
| License | Apache 2.0 | Proprietary | MIT | LGPL |
Everything below is in the shipping public API. For what changed in which release, see CHANGELOG.md — it is not duplicated here, so it cannot drift out of date here either.
Composition
- Result-builder DSL over
Video,Track,VideoClip,ImageClipandTitleSequence,async/awaitthroughout, no third-party dependencies. - Multi-track timelines with named lanes and per-track opacity; time-anchored audio tracks.
KadrVideoCompositorfor custom per-frame compositing, andmakePlayerItem()so a composition previews before it exports.
Transform and animation
Transform(center:rotation:scale:anchor:)on every clip type — picture-in-picture, scaled cutaways, rotated clips.Animation<T>withAnimatableconformances forTransform,Double,PositionandSize.TimingFunctioncovers linear, ease-in/out, cubic Bézier and custom closures.- Keyframes are clip-relative: a keyframe at
0.0maps to the clip's first frame, not composition zero. The same animations drive export and live preview.
Filters
- Animatable presets including brightness, contrast, saturation, exposure, sepia, gaussian blur, vignette, sharpen, zoom blur and glow.
- Keyed by
FilterID, so animations bound to a filter survive reordering instead of drifting onto their neighbours.
Overlays and text
TextOverlaywith built-in animation recipes (.fadeIn,.slideIn,.scaleUp), plusImageOverlayandStickerOverlaywith animatable position and size.TextStylecarriesTextStrokeandTextShadow— legible copy over busy footage.
Audio
- Per-track volume, fades, ducking,
volumeRamp(start:end:during:)and declaration-orderedcrossfade(_:). - Pitch-preserving speed from 0.25× to 4× via
AudioTimePitchAlgorithm(.spectral,.timeDomain,.varispeed).
Timing
Speedis.flat(Double)or.curved(Animation<Double>)— compile-time exclusivity, with non-linear playback rates integrated into a piecewise-linear time map that audio follows.
Captions
Captionvalue type andVideo.captions(_:)bake cues as anAVMetadataItemgroup at export. File parsing for SRT, VTT, iTT, ASS and SSA lives inkadr-captions; the core stays a bridge.
Export
ThumbnailGeneratorreuses oneAVAssetImageGeneratoracross many frame requests, with a batchAsyncThrowingStreamfor filmstrips and cooperativecancel().CancellationTokenbacked by real synchronisation, not an unchecked claim.
See ROADMAP.md for the full version plan.
// Slideshow with background music
let url = try await Video {
ImageClip(photo1)
ImageClip(photo2)
ImageClip(photo3)
}
.audio(url: musicURL)
.export(to: outputURL)
// Merge and trim video clips for Reels
let url = try await Video {
VideoClip(url: clip1URL).trimmed(to: 0...10)
VideoClip(url: clip2URL).trimmed(to: 5...15)
}
.preset(.reelsAndShorts)
.export(to: outputURL)
// Replace audio on a video
let url = try await Video {
VideoClip(url: originalURL).muted()
}
.audio(url: newSoundtrackURL)
.export(to: outputURL)
// Transitions, slow-mo, and ducking music (v0.2)
let url = try await Video {
VideoClip(url: introURL).trimmed(to: 0...3)
Transition.dissolve(duration: 0.5)
VideoClip(url: actionURL).trimmed(to: 0...4).speed(0.5) // half-speed slow-mo
Transition.slide(direction: .fromRight, duration: 0.4)
VideoClip(url: outroURL).trimmed(to: 0...3)
}
.audio { AudioTrack(url: musicURL).volume(0.8).ducking(0.2) } // music dips when clips speak
.export(to: outputURL)
// Title card, color-graded clip, watermark, and music (v0.3)
let url = try await Video {
TitleSequence("MY MOVIE",
duration: 2.0,
style: TextStyle(fontSize: 96, alignment: .center, weight: .bold))
Transition.fade(duration: 0.5)
VideoClip(url: clipURL).trimmed(to: 0...10)
.filter(.brightness(0.05), .contrast(1.1), .saturation(1.2))
}
.overlay(
TextOverlay("LOCATION: HQ", style: TextStyle(fontSize: 40, weight: .medium))
.position(.bottom)
.anchor(.bottom)
)
.watermark(logo, position: .topRight, opacity: 0.5)
.crop(at: .center, size: .normalized(width: 0.9, height: 0.9))
.backgroundMusic(url: musicURL) // defaults: 60% volume, fades, ducking
.export(to: outputURL)
// Multi-track timeline with PiP and a parallel Track block (v0.6)
let url = try await Video {
VideoClip(url: mainURL).trimmed(to: 0...10)
VideoClip(url: pipURL).trimmed(to: 0...3).at(time: 2.0)
Track(at: 5.0, name: "B-Roll") {
VideoClip(url: rollA).trimmed(to: 0...2)
Transition.dissolve(duration: 0.3)
VideoClip(url: rollB).trimmed(to: 0...2)
}
}
.export(to: outputURL)
// Time-pinned sound effects + windowed multi-input compositor (v0.7)
let url = try await Video {
VideoClip(url: baseURL).trimmed(to: 0...8)
VideoClip(url: overlayURL).trimmed(to: 0...8).at(time: 0)
}
.compositor(MultiplyBlend(), during: 2.0...5.0) // custom blend in window
.audio {
AudioTrack(url: musicURL).volume(0.6).ducking(0.2)
AudioTrack(url: stingURL).at(time: 5.0).duration(0.5) // SFX punches in
}
.export(to: outputURL)
// Animated filter sweep + animated text reveal + audio crossfade (v0.8)
let url = try await Video {
VideoClip(url: clipURL).trimmed(to: 0...4)
.filter(.gaussianBlur(radius: 0), animation: .keyframes([
.at(0.0, value: 20), // start blurred
.at(2.0, value: 0), // focus pulls in
], timing: .easeOut))
}
.overlay(
TextOverlay("CHAPTER ONE", style: TextStyle(fontSize: 80, weight: .bold))
.position(.center)
.visible(during: 0.0...2.0)
.animation(.scaleUp(duration: 0.5))
)
.audio {
AudioTrack(url: musicAURL).at(time: 0).duration(3.0).crossfade(0.5)
AudioTrack(url: musicBURL).at(time: 2.5)
}
.export(to: outputURL)
// Export with progress tracking
let exporter = Video {
VideoClip(url: longVideoURL)
}
.preset(.cinema)
.exporter(to: outputURL)
for try await progress in exporter.run() {
print("\(Int(progress.fractionCompleted * 100))%")
}Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/SteliyanH/kadr.git", from: "1.0.0")
]from: is now the correct form. It means .upToNextMajor — >=1.0.0, <2.0.0 — and from v1.0.0 onward nothing in that range breaks you. That is the
whole content of the 1.0 promise; see API stability.
Before 1.0 this README told you to pin .upToNextMinor, because from: does
not special-case 0.x: from: "0.15.0" resolved as >=0.15.0, <1.0.0 and
accepted every 0.x release, breaking ones included — and minors did break, as
when v0.15.0 raised the platform floor to iOS 17. That advice is no longer
needed, and if you are still pinned to a 0.x minor you can move to from: "1.0.0" in one step.
Or in Xcode: File > Add Package Dependencies > enter the repository URL.
Requires: Xcode 16+ / Swift 6.0+
From v1.0.0, kadr follows semantic versioning without asterisks:
- No breaking change without a major bump. Nothing public is removed,
renamed, or given a different meaning inside
1.x. - Minors add. New filters, new modifiers, new overlay kinds — all additive, all source-compatible.
- Deprecation before removal. Anything on its way out is marked
@available(*, deprecated:)for at least one minor with a named replacement, and can only actually disappear in a major.
The surface frozen here is the one as of v0.22.0, not v0.14 as an earlier roadmap said — v0.15 through v0.22 added the platform floor, per-clip volume, waveform resampling, export quality, and the filter catalogue, and all of that is inside the commitment.
What this does not promise: internal symbols, the exact bytes an export
produces (encoders change under us), and performance figures — those are tracked
as a regression baseline in Benchmarks/README.md, not
as a contract.
| Platform | Minimum Version |
|---|---|
| iOS | 17.0 |
| macOS | 14.0 |
| tvOS | 17.0 |
| visionOS | 1.0 |
Platform floor raised in v0.15.0 (was iOS 16 / macOS 13 / tvOS 16). Aligns the ecosystem on the iOS 17 baseline for the
@Observablemigration inkadr-reels-studio. Stay on0.14.xif you need the iOS 16 floor.
Kadr separates the public DSL from the internal engine:
- DSL layer (public, semver-stable) —
Video,Track,VideoClip,ImageClip,TitleSequence,Transition,AudioTrack,Preset,Exporter,Filter,Animation<T>,Transform, plus the overlay / compositor / animation surfaces. - Engine layer (internal, uses AVFoundation) —
CompositionBuilder(timeline assembly + multi-track routing),FilterProcessor(per-frameCIFilterpre-render with intensity animation),KadrVideoCompositor(customAVVideoCompositingfor multi-input compositors),OverlayRenderer(CALayer tree forAVVideoCompositionCoreAnimationTool),PlaybackComposer(AVPlayerItemfor previews),ExportEngine(AVAssetExportSessiondriver),ImageEncoder(still-image fast path),ReverseProcessor.
The DSL is the stable public API. The engine is the implementation detail that can be refactored without breaking semver.
Contributions are welcome! See CONTRIBUTING.md for guidelines.
Apache 2.0 — see LICENSE for details.
Contributions are accepted under the Contributor License Agreement, which is signed once and covers all future contributions. It does not transfer ownership — you keep the copyright in your work.
Apache 2.0 was chosen over MIT for its explicit patent grant, which is relevant for video processing code that touches codec patents (H.264, HEVC).