<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[OmniGIF]]></title><description><![CDATA[OmniGIF]]></description><link>https://omnigif.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aabf94a1cb305afe6684c15/7bf0b5b5-6778-47d0-ba7f-6462e083ffc1.png</url><title>OmniGIF</title><link>https://omnigif.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 22 Sep 2026 17:14:45 GMT</lastBuildDate><atom:link href="https://omnigif.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How OmniGIF Converts Live Photos to GIF Entirely in the Browser]]></title><description><![CDATA[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 ]]></description><link>https://omnigif.hashnode.dev/how-omnigif-converts-live-photos-to-gif-entirely-in-the-browser</link><guid isPermaLink="true">https://omnigif.hashnode.dev/how-omnigif-converts-live-photos-to-gif-entirely-in-the-browser</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[FFmpeg]]></category><category><![CDATA[#WebCodecs ]]></category><category><![CDATA[gif]]></category><category><![CDATA[video]]></category><dc:creator><![CDATA[孙珂]]></dc:creator><pubDate>Thu, 17 Sep 2026 14:43:39 GMT</pubDate><content:encoded><![CDATA[<p>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. <a href="https://www.omnigif.com/live-to-gif">OmniGIF's Live Photo to GIF converter</a> does the opposite: the MOV/MP4 never leaves the device.</p>
<p>This post walks through how that pipeline is built — from Apple's export format, through engine selection, to palette-based GIF encoding.</p>
<h2>The Live Photo input problem</h2>
<p>A Live Photo in the Photos library is typically a <strong>HEIC + paired video</strong>. Browsers cannot reliably ingest that pair as one drop. Apple's supported path is:</p>
<ol>
<li>Open the Live Photo</li>
<li>Share → <strong>Save as Video</strong></li>
<li>Upload the exported <strong>MOV</strong> or <strong>MP4</strong></li>
</ol>
<p>That export is usually ~1.5–3 seconds of motion — short enough for a GIF, long enough to feel "alive." OmniGIF accepts <code>.mov</code> / <code>.mp4</code> (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.</p>
<p>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.</p>
<h2>High-level architecture</h2>
<pre><code>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
</code></pre>
<p>The Live Photo page is a thin specialization of a shared <strong>media converter</strong> used by Video to GIF, MOV to GIF, MP4 to GIF, and related tools. Same options model (<code>VideoToGifOptions</code>), same analytics, same UI shell — different <code>accept</code> list and copy.</p>
<p>Stack choices:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Choice</th>
</tr>
</thead>
<tbody><tr>
<td>App shell</td>
<td>Next.js 15 (SSG) + React 19</td>
</tr>
<tr>
<td>Fast path</td>
<td><a href="https://github.com/Vanilagy/mediabunny">Mediabunny</a> + WebCodecs + Canvas</td>
</tr>
<tr>
<td>Compatible path</td>
<td><a href="https://ffmpegwasm.netlify.app/">ffmpeg.wasm</a> in a Web Worker</td>
</tr>
<tr>
<td>GIF encode (fast path)</td>
<td>gif.js / shared <code>encodeFramesToGifBlob</code></td>
</tr>
<tr>
<td>Hosting</td>
<td>Cloudflare (OpenNext)</td>
</tr>
</tbody></table>
<p>No server receives the file. After the page loads, conversion is local compute.</p>
<h2>Dual engines, one contract</h2>
<p>Every converter implements the same interface:</p>
<ul>
<li><code>canHandle(input, options)</code></li>
<li><code>convert(file, options, callbacks)</code></li>
<li><code>cancel()</code> / <code>dispose()</code></li>
</ul>
<p>Callbacks report <strong>stage</strong> (<code>loading-engine</code> → <code>decoding</code> → <code>processing</code> → <code>encoding</code> → <code>completed</code>) and a <strong>progress ratio</strong>, so the UI can show meaningful feedback even when WASM does not emit smooth <code>progress</code> events.</p>
<h3>Engine A — WebCodecs + Mediabunny (preferred)</h3>
<p>When the browser can decode the container (MOV/MP4/WebM/MKV-like) and is not Safari-preferring-FFmpeg:</p>
<ol>
<li>Open the file with Mediabunny <code>Input</code> + <code>BlobSource</code></li>
<li>Take the primary video track; verify <code>canDecode()</code></li>
<li>Sample frames at the target FPS between <code>startSeconds</code> and <code>endSeconds</code> via <code>CanvasSink</code></li>
<li>Apply crop / circular mask in Canvas → <code>ImageData</code></li>
<li>Encode frames to GIF with palette quality derived from the color budget</li>
</ol>
<p>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.</p>
<h3>Engine B — FFmpeg.wasm in a Worker (fallback / Safari / hard cases)</h3>
<p>FFmpeg runs in a <strong>module Worker</strong> with the <strong>single-thread</strong> <code>@ffmpeg/core</code> build. That avoids requiring <code>SharedArrayBuffer</code> / cross-origin isolation, which would conflict with many third-party scripts on a marketing site.</p>
<p>Flow inside the worker:</p>
<ol>
<li>Lazy-load <code>ffmpeg-core.js</code> + <code>.wasm</code> from a CDN into Blob URLs</li>
<li>Write the uploaded buffer to the virtual FS as <code>input.mov</code> / <code>input.mp4</code> / …</li>
<li><code>exec</code> a carefully built argv list</li>
<li>Read <code>output.gif</code>, transfer the <code>ArrayBuffer</code> back to the UI thread</li>
<li>Delete temp files; support cancel via <code>terminate()</code> + generation tokens</li>
</ol>
<p>Palette filters often report <code>progress ≈ 0</code>. The main-thread engine layers a <strong>soft asymptotic progress ticker</strong>, overridden whenever real FFmpeg time/progress arrives — so the bar still moves on phones.</p>
<h2>How the engine is chosen</h2>
<p>Selection is capability-driven, not page-driven. Live Photo → GIF uses the same rules as other video→GIF pages:</p>
<ol>
<li><strong>AVI</strong> or containers that do not prefer WebCodecs → FFmpeg</li>
<li><strong>Safari</strong> (weaker WebCodecs reliability) → prefer FFmpeg</li>
<li>Missing <code>VideoDecoder</code> or unknown width/height from header probe → FFmpeg</li>
<li>Otherwise try WebCodecs; if <code>canHandle</code> fails → FFmpeg</li>
</ol>
<p>If WebCodecs starts and throws a <strong>recoverable</strong> error (unsupported codec, empty frames, canvas failure), the conversion hook <strong>automatically falls back</strong> to a fresh FFmpeg engine and marks <code>usedFallback: true</code> for analytics. Users see a short "compatible mode" state instead of a hard failure.</p>
<h2>Building a good GIF from video</h2>
<p>GIF is at most <strong>256 colors per frame</strong>. Naïve frame dumps look posterized. OmniGIF uses two complementary strategies.</p>
<h3>Fast path: per-frame quantize + optional dither</h3>
<p>Decoded canvases become <code>GIFFrame[]</code> with delay <code>1000 / fps</code>. Encoder quality is mapped from the <code>colors</code> setting. Circular crop reserves transparency so the GIF can be a round sticker-style clip.</p>
<h3>FFmpeg path: two-pass palette</h3>
<p>Args are built in pure TypeScript (no string-interpolated user paths). Conceptually:</p>
<pre><code class="language-text">[0:v] crop?, fps, scale=lanczos, (optional circle alpha)
  → split
  → palettegen (max_colors, stats_mode=diff)
  → paletteuse (dither=bayer|floyd_steinberg|sierra2_4a|…)
</code></pre>
<p>Also supported in the filter chain:</p>
<ul>
<li><strong>Trim</strong> via <code>-ss</code> / <code>-t</code> <em>after</em> <code>-i</code> (more reliable on awkward containers)</li>
<li><strong>Speed</strong> by adjusting effective FPS (<code>fps / speed</code>)</li>
<li><strong>Loop</strong> count (<code>-loop</code>)</li>
<li><strong>Even dimensions</strong> (encoder-friendly)</li>
<li><strong>Aspect-preserving scale</strong> (<code>scale=W:-1</code>) so crop regions are not stretched</li>
</ul>
<p>Presets (<code>small</code> / <code>balanced</code> / <code>high</code>) set default width, FPS, colors, and dither. Live Photos default to a <strong>centered square crop</strong> because vertical phone footage rarely needs full frame for a chat GIF.</p>
<h2>UX details that matter for Live Photos</h2>
<p><strong>Preview before convert.</strong> 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 <code>frames × resolution × color complexity</code>.</p>
<p><strong>Mobile guide.</strong> Phones get a one-shot modal explaining "Save as Video," with optional "don't show again today" via <code>localStorage</code> keyed by local date. Confirming the modal must call the file picker in the <strong>same tap</strong> or Safari will block it.</p>
<p><strong>Privacy analytics.</strong> PostHog events carry tool id, engine id, duration, and error codes — not filenames or pixel data.</p>
<p><strong>Soft limits.</strong> Duration, resolution, and file size caps fail early with clear errors rather than mid-encode OOMs.</p>
<h2>Why not upload to a server?</h2>
<p>Client-side conversion is slower than a beefy GPU box for long 4K clips — but Live Photos are short. The tradeoffs win:</p>
<ul>
<li>No retention policy for intimate phone videos</li>
<li>No GDPR transfer of the media itself</li>
<li>Works after first load with cached Wasm / scripts</li>
<li>Same codebase for Live Photo, MOV, and MP4 tools</li>
</ul>
<p>When WebCodecs wins, first conversion can feel near-instant. When FFmpeg loads, idle-time preload (<code>requestIdleCallback</code>) on related pages softens the cold start.</p>
<h2>Try it</h2>
<ul>
<li>Tool: <a href="https://www.omnigif.com/live-to-gif">https://www.omnigif.com/live-to-gif</a></li>
<li>Apple's Live Photo help: <a href="https://support.apple.com/en-us/104966">Take and edit Live Photos</a></li>
<li>GIF format background: <a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types">MDN image types</a></li>
</ul>
<p>Built as part of <a href="https://www.omnigif.com">OmniGIF</a> — a client-side GIF toolkit. Feedback welcome via <a href="https://www.omnigif.com/contact">Contact</a>.</p>
]]></content:encoded></item></channel></rss>