steliyanh/kadr-persistence
Save a kadr composition to a file, and open it again.
Why this is a package and not thirty lines in your app
kadr's Video cannot be Codable. It holds [any Compositor] and TimingFunction.custom — closures — and a PlatformImage, which is pixels with no record of where they came from. So every app that saves a kadr project ends up hand-writing a mirror of the DSL.
A hand-written mirror has one failure mode, and it is a bad one: you add a field to a clip, forget to add it to the mirror, and nothing fails. Not the compiler, not the round-trip test — a field missing from both sides of a comparison compares equal. The project saves without complaint and reopens subtly wrong, and you find out weeks later when a user asks why their titles lost their colour.
That has already happened in this ecosystem more than once. This package exists so it happens no more times.
What it will not do
It will not drop something silently. Encoding refuses when the composition holds content a file cannot represent:
do {
let data = try KadrCoding.data(for: video)
} catch let error as PersistenceError {
print(error.localizedDescription)
// "A custom compositor on clip “hero” can't be saved — it's code, not data."
}Ask before committing, so you can warn rather than apologise:
let losses = KadrCoding.lossyContent(in: video)
if losses.isEmpty {
try save()
} else {
show(losses.map(\.describedForUser)) // save anyway, or go back and change it
}And save anyway when the user says so:
let data = try KadrCoding.data(for: video, allowingLoss: true)Five things cannot be represented, and all five are reported rather than dropped:
| What | Why | |---|---| | Compositor on a clip | per-frame Core Image code | | MultiInputCompositor on the composition | the same | | TimingFunction.custom | an easing closure | | TextAnimation on an overlay | an existential with open-ended conformers | | A PlatformImage with no ImageStore | pixels with no identity to write down |
Images
An ImageClip holds a decoded image, not a reference to one. A document can't write that: embedding the bytes turns a ten-photo slideshow into a hundred megabytes of base64, and every editor worth the name references its media rather than swallowing it.
So the identity comes from you — the only layer that knows it:
struct PhotoLibraryStore: ImageStore {
func token(for image: PlatformImage) throws -> String { /* the asset's local id */ }
func image(for token: String) throws -> PlatformImage { /* fetch it back */ }
}
let data = try KadrCoding.data(for: video, images: PhotoLibraryStore())
let restored = try KadrCoding.video(from: data, images: PhotoLibraryStore())Tokens are opaque here: any stable string works, as long as the same store resolves it later. Without a store, images are reported as lossy — never written with a placeholder that fails on open.
The format
Plain JSON with sorted keys, so two saves of an unchanged project are byte-identical and a diff is readable.
{"schema":1,"video":{"audioTracks":[],"captions":[],"clips":[{"video":{...}}],...}}Times are stored as value/timescale, not as seconds. CMTime(value: 1, timescale: 30) written as 0.0333… and read back is no longer frame 1 of a 30 fps timeline, and an editor that snaps to frames will place the clip a frame short.
schema is refused when it is newer than this version understands. Reading a future document on a best-effort basis means dropping the fields you don't know about and erasing them on the next save — a refusal is the only behaviour that cannot destroy a project.
What is stored
Everything else. Clips (video, image, title, transition, and nested tracks), trims, speed and speed curves, filters with their identities, transforms, opacity and its animations, audio tracks with ramps and pitch algorithm, overlays, crop, captions, preset, and export quality.
That list is enforced, not asserted: CompletenessTests reflects over each kadr type and fails when it grows a stored property this package hasn't been taught to handle. It is the reason a kadr upgrade cannot quietly start losing your data — and it caught six missing fields on its first run.
Installation
.package(url: "https://github.com/SteliyanH/kadr-persistence.git", .upToNextMinor(from: "0.6.0")).upToNextMinor rather than from: — this is pre-1.0, where from: would accept breaking changes.
Requirements
Swift 6 · iOS 17 · macOS 14 · tvOS 17 · visionOS 1 · kadr 1.0+
Support
If this saved you an afternoon: buy me a coffee.
The kadr ecosystem
| Package | Purpose | |---|---| | kadr | The engine. Declarative video composition and export — clips, tracks, transitions, filters, overlays, keyframe animation. | | kadr-ui | SwiftUI components — preview, timeline, inspector, overlay host, keyframe editor. | | kadr-persistence | Save a composition to a file and open it again. | | kadr-audio | Music library, voiceover recording, LUFS loudness. | | kadr-captions | SRT, VTT, iTT, ASS and SSA parsing and authoring. | | kadr-photos | Photos library integration. |
And a reference application: Kadr Studio, a short-form vertical video editor built on all six.
License
Apache-2.0. See LICENSE — the same licence as the rest of the kadr ecosystem.
Contributions are accepted under the Contributor License Agreement, signed once and covering all future contributions. It does not transfer ownership — you keep the copyright in your work.
Package Metadata
Repository: steliyanh/kadr-persistence
Default branch: main
README: README.md