iPhone Live Photos are not a single GIF-ready file. They are a still image plus a short video clip. Most "Live Photo to GIF" tools ask you to upload that clip to a server. OmniGIF's Live Photo to GIF converter does the opposite: the MOV/MP4 never leaves the device.
This post walks through how that pipeline is built — from Apple's export format, through engine selection, to palette-based GIF encoding.
The Live Photo input problem
A Live Photo in the Photos library is typically a HEIC + paired video. Browsers cannot reliably ingest that pair as one drop. Apple's supported path is:
- Open the Live Photo
- Share → Save as Video
- Upload the exported MOV or MP4
That export is usually ~1.5–3 seconds of motion — short enough for a GIF, long enough to feel "alive." OmniGIF accepts .mov / .mp4 (and the matching MIME types) and rejects raw HEIC Live Photo pairs on purpose. Forcing a clean video export keeps decoding deterministic across Chrome, Safari, and Firefox.
On mobile, first-time users often try to pick the Live Photo still instead of the video. The page intercepts the file picker on narrow viewports and shows a short "Save as Video" guide GIF before opening the picker — same user gesture, so iOS Safari still allows the file dialog.
High-level architecture
User exports Live Photo as MOV/MP4
↓
Browser: analyze container + duration + resolution
↓
Preview + crop + timeline trim (client UI)
↓
selectConversionEngine()
├─ WebCodecs path (Mediabunny decode → gif.js encode)
└─ FFmpeg.wasm path (Worker + palettegen/paletteuse)
↓
GIF Blob → object URL → download
The Live Photo page is a thin specialization of a shared media converter used by Video to GIF, MOV to GIF, MP4 to GIF, and related tools. Same options model (VideoToGifOptions), same analytics, same UI shell — different accept list and copy.
Stack choices:
| Concern | Choice |
|---|---|
| App shell | Next.js 15 (SSG) + React 19 |
| Fast path | Mediabunny + WebCodecs + Canvas |
| Compatible path | ffmpeg.wasm in a Web Worker |
| GIF encode (fast path) | gif.js / shared encodeFramesToGifBlob
|
| Hosting | Cloudflare (OpenNext) |
No server receives the file. After the page loads, conversion is local compute.
Dual engines, one contract
Every converter implements the same interface:
canHandle(input, options)convert(file, options, callbacks)-
cancel()/dispose()
Callbacks report stage (loading-engine → decoding → processing → encoding → completed) and a progress ratio, so the UI can show meaningful feedback even when WASM does not emit smooth progress events.
Engine A — WebCodecs + Mediabunny (preferred)
When the browser can decode the container (MOV/MP4/WebM/MKV-like) and is not Safari-preferring-FFmpeg:
- Open the file with Mediabunny
Input+BlobSource - Take the primary video track; verify
canDecode() - Sample frames at the target FPS between
startSecondsandendSecondsviaCanvasSink - Apply crop / circular mask in Canvas →
ImageData - Encode frames to GIF with palette quality derived from the color budget
This path stays on the main thread for canvas work but avoids downloading a multi‑MB Wasm binary when hardware decoding works. Frame count is capped (order of hundreds) so a mis-set FPS cannot OOM a phone.
Engine B — FFmpeg.wasm in a Worker (fallback / Safari / hard cases)
FFmpeg runs in a module Worker with the single-thread @ffmpeg/core build. That avoids requiring SharedArrayBuffer / cross-origin isolation, which would conflict with many third-party scripts on a marketing site.
Flow inside the worker:
- Lazy-load
ffmpeg-core.js+.wasmfrom a CDN into Blob URLs - Write the uploaded buffer to the virtual FS as
input.mov/input.mp4/ … -
execa carefully built argv list - Read
output.gif, transfer theArrayBufferback to the UI thread - Delete temp files; support cancel via
terminate()+ generation tokens
Palette filters often report progress ≈ 0. The main-thread engine layers a soft asymptotic progress ticker, overridden whenever real FFmpeg time/progress arrives — so the bar still moves on phones.
How the engine is chosen
Selection is capability-driven, not page-driven. Live Photo → GIF uses the same rules as other video→GIF pages:
- AVI or containers that do not prefer WebCodecs → FFmpeg
- Safari (weaker WebCodecs reliability) → prefer FFmpeg
- Missing
VideoDecoderor unknown width/height from header probe → FFmpeg - Otherwise try WebCodecs; if
canHandlefails → FFmpeg
If WebCodecs starts and throws a recoverable error (unsupported codec, empty frames, canvas failure), the conversion hook automatically falls back to a fresh FFmpeg engine and marks usedFallback: true for analytics. Users see a short "compatible mode" state instead of a hard failure.
Building a good GIF from video
GIF is at most 256 colors per frame. Naïve frame dumps look posterized. OmniGIF uses two complementary strategies.
Fast path: per-frame quantize + optional dither
Decoded canvases become GIFFrame[] with delay 1000 / fps. Encoder quality is mapped from the colors setting. Circular crop reserves transparency so the GIF can be a round sticker-style clip.
FFmpeg path: two-pass palette
Args are built in pure TypeScript (no string-interpolated user paths). Conceptually:
[0:v] crop?, fps, scale=lanczos, (optional circle alpha)
→ split
→ palettegen (max_colors, stats_mode=diff)
→ paletteuse (dither=bayer|floyd_steinberg|sierra2_4a|…)
Also supported in the filter chain:
-
Trim via
-ss/-tafter-i(more reliable on awkward containers) -
Speed by adjusting effective FPS (
fps / speed) -
Loop count (
-loop) - Even dimensions (encoder-friendly)
-
Aspect-preserving scale (
scale=W:-1) so crop regions are not stretched
Presets (small / balanced / high) set default width, FPS, colors, and dither. Live Photos default to a centered square crop because vertical phone footage rarely needs full frame for a chat GIF.
UX details that matter for Live Photos
Preview before convert. After upload, metadata analysis fills duration and resolution; the UI shows a video preview with crop handles and a timeline. Estimated output size updates as options change — important because GIF size grows roughly with frames × resolution × color complexity.
Mobile guide. Phones get a one-shot modal explaining "Save as Video," with optional "don't show again today" via localStorage keyed by local date. Confirming the modal must call the file picker in the same tap or Safari will block it.
Privacy analytics. PostHog events carry tool id, engine id, duration, and error codes — not filenames or pixel data.
Soft limits. Duration, resolution, and file size caps fail early with clear errors rather than mid-encode OOMs.
Why not upload to a server?
Client-side conversion is slower than a beefy GPU box for long 4K clips — but Live Photos are short. The tradeoffs win:
- No retention policy for intimate phone videos
- No GDPR transfer of the media itself
- Works after first load with cached Wasm / scripts
- Same codebase for Live Photo, MOV, and MP4 tools
When WebCodecs wins, first conversion can feel near-instant. When FFmpeg loads, idle-time preload (requestIdleCallback) on related pages softens the cold start.
Try it
- Tool: https://www.omnigif.com/live-to-gif
- Apple's Live Photo help: Take and edit Live Photos
- GIF format background: MDN image types
- YouTube video: https://www.youtube.com/watch?v=7LSQiUI4J_M
Built as part of OmniGIF — a client-side GIF toolkit. Feedback welcome via Contact.
Top comments (0)