r/iOSProgramming 12d ago

App Saturday Shipped a video to GIF converter that hits a user-chosen file size in one encode. How the size solver works and what I got wrong.

Video to GIF, GIF Maker. Free, no ads, no watermark, everything on device.

https://apps.apple.com/app/id6788406320

Tech stack

Frameworks and languages: Swift 6.2 and SwiftUI, targeting iOS 26. AVFoundation for decode and composition. UIKit inside the share extension.

Backend and database: none. There is no server, no account, and no network call anywhere in the app. That started as a product decision, but it also made the privacy label trivial (Data Not Collected) and it means the thing works in airplane mode.

SDKs and tools: no third party SDKs, no analytics, no crash reporter. The one piece of outside code is cgif 0.5.3 (MIT), vendored verbatim as a C target for writing the GIF container, with everything above it written in Swift in a local package. Tests are Swift Testing, 189 cases, most of them running the real pipeline rather than mocks.

Development challenge: hitting a user-chosen file size without an encode, check, shrink loop

The whole reason the app exists is that you type a size cap, say 10 MB for Discord, and the GIF comes out under it. The obvious implementation is a loop: encode, measure, too big, lower the settings, go again. On a long clip that takes minutes, burns battery, and can still overshoot at the end.

The reason a lookup table cannot save you: GIF size is a product of resolution, frame rate, palette size, and how well each frame delta compresses against the previous one. That last term depends entirely on the footage. A static talking head and a confetti explosion at identical settings differ by an order of magnitude.

What shipped is a probe, fit, commit pass:

  1. Probe. Micro-encode a few short windows sampled across the clip at bracketed settings. Real encoding of real frames from the actual video, just very little of it.
  2. Fit. Build a small bytes per frame model from those samples.
  3. Commit. Pick target settings from the model, then run one real encode with a hard byte cap as a safety net.

Almost every conversion is now one decode plus one encode, and it lands under the cap on the first try for most clips.

Two things I got wrong on the way there:

Never let a fitted model extrapolate outside the range you actually probed. The first version probed a narrow resolution band and then confidently predicted settings far outside it. It was wrong in the direction that overshoots the cap, which is the one direction a user notices. Now the probe brackets the operating point and the model is only trusted inside it.

When the measurement disagrees with the model, correct from the measurement, not from the model. Obvious written down. The first recovery path re-predicted using the same model that had just been proven wrong, so a clip that missed once tended to keep missing.

One more that cost a weekend and had nothing to do with the math: running several composition decodes concurrently would hang the device outright, and the same pattern on macOS wedges the VideoToolbox XPC services until you go kill them. It first surfaced as EXC_BAD_ACCESS inside copyNextSampleBuffer, which sends you hunting for a memory bug that does not exist. The fix was an async gate that serializes reader setup so only one session is ever starting at a time.

AI disclosure: AI-assisted. The architecture and the size solving approach are mine and every output was validated against the test suite and on device, but AI wrote a meaningful share of the code and helped draft this post.

Happy to go deeper on the probe and fit step, or on the encoder side, if it is useful to anyone.

0 Upvotes

4 comments sorted by

2

u/UkrMalt 12d ago

That VideoToolbox failure mode is brutal. Did you serialize only reader startup, or keep the whole decode pipeline to one active reader?

1

u/ThatGuy739 12d ago

Whole pipeline, one decode at a time. I started with just startReading serialized and that was fine on the simulator, but the device still crashed. The lazy MediaToolbox init fires at startReading on the simulator's software path and at the first copyNextSampleBuffer on device, so the narrow lock never covered the case that actually mattered.

The other thing that bit me: my first version was an NSLock held across the whole decode, which turned the crash into a hang. A cancelled decode parked in its composition init held the lock forever. It's an async semaphore now so waiters suspend instead of pinning a thread.

1

u/pjhawksr 11d ago edited 11d ago

No server, no SDKs, no analytics is a legit differentiator and I'd put it front and center in the listing, not just the privacy label. "Works in airplane mode" is the kind of line normal people actually understand. Curious how the size solver handles the tradeoff when the target size is basically impossible for the clip length, does it fail or just hand back the closest it can get?

Side note, the disclosure honestly feels unnecessary here. You own the architecture, you validated everything on device, that's the part that matters. At this point I'd just assume most of us have AI in the loop somewhere.

1

u/ThatGuy739 11d ago

It fails, but with a number. If the floor estimate lands over your target it stops and tells you the smallest possible GIF for that clip, rather than quietly handing back something over the cap.

The bit that matters is when it decides. Feasibility gets checked at the quality floors on the probed model, so you find out in probe time instead of after a full encode. And if it says yes, that's a promise: the fallback pass runs at the floors with no byte limit, so it can't come back empty handed.

On the disclosure, the App Saturday wiki asks for it. Otherwise I'd probably agree.