Releases: SteliyanH/kadr-persistence
Release list
v0.6.0 — images from an asynchronous source
PrefetchedImageStore
ImageStore is synchronous. That fits files and bytes; it does not fit a photo library, where resolving a PHAsset means PHImageManager and a callback.
The obvious fix — an async ImageStore, with async encode/decode to match — was rejected: it makes the encode path async for every consumer, including the many with no images at all, to serve the one case that needs it.
This is the other answer. Resolve first, then encode, with the await left in the layer that already had one:
let document = try JSONDecoder().decode(KadrDocument.self, from: data)
let needed = PrefetchedImageStore.tokens(in: document)
let store = PrefetchedImageStore(try await resolve(needed))
let video = try KadrCoding.decode(document, images: store)tokens(in:) is what makes it work: a document is readable on its own, so the tokens are knowable before the composition is built. The chicken-and-egg problem is only apparent.
An image the store was not given is refused rather than issued an invented token — that would encode cleanly and resolve to nothing on the next open, which is exactly the silent loss this package exists to prevent.
StoringImages
A DocC article on where a composition's pictures live: both stores, the relative-token hazard, pruning, and the one rule for writing your own — a token must be stable across saves, or every save rewrites the whole file.
119 tests.
v0.5.0 — the storage layer, finished
The three pre-1.0 roadmap items, shipped.
FileImageStore
The store most apps would otherwise write themselves: images as files in a directory you nominate, referenced by the project rather than embedded in it.
Tokens are relative — file:<name>.png, resolved against the store's directory — and that is the substantive detail rather than tidiness. An iOS app's container is /var/mobile/Containers/Data/Application/<UUID>/, and that UUID changes: on reinstall, on restore from backup, sometimes across an OS update. An absolute path written into a project on Monday can point nowhere on Friday, with the project itself perfectly intact.
Names are content hashes, so the same image is stored once and a token is stable across saves — re-saving an unchanged project produces identical bytes. prune(keeping:) deletes what a composition no longer refers to. Absolute tokens from hand-rolled stores still resolve, so nothing that exists stops opening.
SchemaMigrator
Steps operate on the raw JSON, not on KadrDocument — because an old document by definition doesn't decode into today's types. That is the whole reason a migration is needed, so a mechanism that requires decoding first can only handle the changes that didn't need it.
A gap in the chain is refused, not skipped: treating a missing step as a no-op hands back a document nobody migrated, which then gets saved over the original.
registered is empty and a test asserts it — a statement about the format's history, not a placeholder. Every change so far has been an added optional field. The runner is proven today against synthetic steps, because the day a migration is needed is the worst day to find out the mechanism doesn't work.
A committed fixture corpus
A broad schema-1 document — every clip kind, a nested track, filters with identities, animations, audio ramps, all three overlay kinds, crop, captions — decoded on every run, including a byte-for-byte re-encode.
This is the only test in the package that can catch a change breaking yesterday's files. Every other one encodes and decodes with today's code, so both sides move together and drift cancels out. The generator that writes the corpus is disabled on purpose: a suite that can rewrite its own evidence proves nothing.
Left open, deliberately
Async image resolution (PHImageManager is callback-based while ImageStore is synchronous — making that async changes the encode path for everyone, so it wants a real caller first), and the deletion policy when two projects share one store directory.
111 tests, 39 new.
v0.4.0 — text animations are saved, not refused
Every TextAnimation was reported as Lossy, on the reasoning that the protocol has an open set of conformers. But the three kadr ships — FadeIn, SlideIn, ScaleUp — are plain public structs with public properties.
Reporting them meant a composition built with kadr's own animation picker could not be saved at all under the default strict encoding.
The reference app hit exactly that: adding a fade to a text overlay made autosave throw, so the project silently stopped saving from that point on. It shipped that way in reels-studio v0.12.0, and this is the fix.
A conformer this version doesn't recognise is still reported rather than guessed at — the original principle intact. Duration is stored as a rational like every other time in the format, so a 1/30 s fade is still one frame after a round trip.
Not a schema bump. textAnimation is optional and appended, so a document written by 0.1–0.3 decodes with nil — now covered by a test that strips the field from real output and reads the document back.
This also answers the ROADMAP's open question, and the answer is that it needed no upstream change at all: an existential isn't automatically unencodable, only where its conformers are.
73 tests, 11 new.
v0.3.0 — public initialisers
Swift synthesises a memberwise initialiser for a public struct, but that initialiser is internal. So all 27 mirror types in this package were readable from outside the module and impossible to construct.
A document format whose types only its own package can build is half a format. A migration tool, a test fixture in a host app, or anything writing a project without going through a Video hits the same wall — which is where this was found: a list-row test in kadr-reels-studio that needed a PresetData.
let document = KadrDocument(video: VideoData(
clips: [.transition(TransitionData(kind: "fade", duration: half, direction: nil))],
audioTracks: [], preset: PresetData(kind: "tiktok", ...), overlays: [],
crop: nil, quality: QualityData(kind: "automatic", ...), captions: []
))62 tests.
v0.2.0 — adopts kadr 1.0
Adopts kadr 1.0, and takes the API kadr 1.0 shipped because this package asked for it.
ChromaKey round-trips through its own properties
ChromaKey exposed color as ColorComponents but only initialised from a PlatformColor, so it could not be rebuilt from its own public surface — this package had to detour through PlatformColor, which is lossy on macOS for anything outside sRGB. kadr 1.0 added ChromaKey(color:threshold:); decoding now goes straight through the components, and the open question is struck from the ROADMAP.
Changed
- kadr floor raised to
1.0.0, pinned withfrom:rather than.upToNextMinor. Pre-1.0 this package accepted exactly one kadr minor, so an app using both had to match it exactly.
60 tests pass.
v0.1.0 — the document format, and an encoder that won't lie about it
Save a kadr composition to a file, and open it again.
let data = try KadrCoding.data(for: video) // save
let video = try KadrCoding.video(from: data) // openWhy this is a package
kadr's Video cannot be Codable. It holds [any Compositor] and TimingFunction.custom — closures — and PlatformImage, which is pixels with no record of where they came from. So every app that saves a kadr project hand-writes a mirror of the DSL.
A hand-written mirror has one failure mode, and it is a bad one: you add a field upstream, forget 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.
It will not drop something silently
Encoding refuses by default when the composition holds content a file cannot represent, and lossyContent(in:) lets you ask before committing:
let losses = KadrCoding.lossyContent(in: video)
// "A custom compositor on clip “hero” can't be saved — it's code, not data."Five things cannot be represented — clip compositors, the composition's compositor, custom timing closures, text animations, and images with no ImageStore. All five are reported. allowingLoss: true saves anyway and tells you what it left behind.
What round-trips
Video, image, title and transition clips, and nested tracks. Trims, reversal, muting, replacement audio, volume, speed and speed curves. Filters with their FilterIDs. Transforms, opacity, and their animations. Audio tracks with volume ramps and pitch algorithm. Text, image and sticker overlays. Crop, captions, preset, export quality.
The completeness guard
CompletenessTests reflects over each kadr type and asserts its stored properties are exactly the set this package handles. It is the only test here that can catch the bug the package exists to prevent, and it caught six missing Video fields — including quality, an entire kadr release — on its first run, against this package's own first draft.
Notes
Times are stored as value/timescale, not seconds, so frame boundaries survive exactly. JSON keys are sorted, so two saves of an unchanged project are byte-identical. A document from a newer schema is refused rather than read best-effort, because a best-effort read erases the fields it didn't understand on the next save.
Building this found four API gaps in kadr, fixed upstream in v0.21.0.
Requires Swift 6, kadr 0.21+. 60 tests across four suites.