DEV Community

Calvin
Calvin

Posted on Originally published at omnigif.com

How OmniGIF converts Live Photos to GIF entirely in the Browser

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:

  1. Open the Live Photo
  2. Share → Save as Video
  3. 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
Enter fullscreen mode Exit fullscreen mode

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-enginedecodingprocessingencodingcompleted) 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:

  1. Open the file with Mediabunny Input + BlobSource
  2. Take the primary video track; verify canDecode()
  3. Sample frames at the target FPS between startSeconds and endSeconds via CanvasSink
  4. Apply crop / circular mask in Canvas → ImageData
  5. 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:

  1. Lazy-load ffmpeg-core.js + .wasm from a CDN into Blob URLs
  2. Write the uploaded buffer to the virtual FS as input.mov / input.mp4 / …
  3. exec a carefully built argv list
  4. Read output.gif, transfer the ArrayBuffer back to the UI thread
  5. 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:

  1. AVI or containers that do not prefer WebCodecs → FFmpeg
  2. Safari (weaker WebCodecs reliability) → prefer FFmpeg
  3. Missing VideoDecoder or unknown width/height from header probe → FFmpeg
  4. Otherwise try WebCodecs; if canHandle fails → 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|…)
Enter fullscreen mode Exit fullscreen mode

Also supported in the filter chain:

  • Trim via -ss / -t after -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

Built as part of OmniGIF — a client-side GIF toolkit. Feedback welcome via Contact.

Top comments (0)