Embeddy converts video, GIF and animated WebP into the sizes Discord, Telegram and Slack will actually accept, inspects a URL's Open Graph card the way a chat client would render it, compresses stills to WebP/JPEG/PNG/AVIF, and uploads anonymously with EXIF stripped. One repository ships all four tools twice — as a Kotlin/Compose Android app and as an offline-capable browser app — plus the Cloudflare Worker that backs the two features a browser can't do alone.
The hard part is that nothing decodes animated WebP. The bundled FFmpeg build has no demuxer for it, so FFmpeg only ever sees the first frame; Firefox ships no ImageDecoder, so the web had no per-frame access either. So the format got parsed by hand, twice. An animated WebP is a RIFF container of ANMF chunks, each holding an ordinary VP8 or VP8L bitstream, so each frame's bitstream is re-wrapped in a synthesized minimal WebP header — fabricating a VP8X block with the alpha flag set when the frame carries a separate alpha chunk — and handed to a plain still-image decoder. The frames are then composited by hand with their offsets, blend modes and disposal methods, and on Android handed back to FFmpeg through a generated concat script so per-frame delays survive instead of being flattened to a constant frame rate.
In the browser, encoding runs libwebp's animation encoder as WASM inside a Web Worker, one frame at a time: an async generator seek-decodes the video lazily, each frame's buffer is transferred zero-copy into the Worker, and only one frame of pixels ever exists in the WASM heap — so a long video encodes in memory proportional to its output, not its length. Twenty-four libwebp parameters are exposed, from spatial noise shaping to near-lossless. Behind the Inspect and Upload tabs, a Hono Worker fetches arbitrary user URLs, which means it needed its own SSRF guard: private, loopback, CGNAT, link-local and cloud-metadata ranges, IPv4-mapped IPv6, integer-encoded addresses, embedded credentials — and every redirect hop revalidated, because otherwise one 302 walks straight into the metadata service.
// Technical highlights
- Two independent hand-written animated-WebP demuxers — Kotlin and TypeScript — that re-wrap each
ANMFframe's VP8/VP8L bitstream in a synthesized RIFF header so a still-image decoder can read it (WebPFrameSplitter.kt:87,webp-frames.ts) - Full frame-compositing state machine: per-frame offset, blend vs replace (PorterDuff SRC), and background disposal, on both platforms (
AnimatedWebPExtractor.kt:74,webp-decoder.ts:199) - Streaming WASM encoder holds one frame at a time in the WASM heap — O(output), not O(frames × pixels) — with 24 libwebp parameters exposed per frame (
streaming-encoder.worker.ts:41) - Android re-feeds FFmpeg through a generated concat script with per-frame durations, so variable delays survive; zero-length frames clamped to 20 ms, last frame re-listed (
AnimatedWebPExtractor.kt:88) - Adaptive quality loop hits Discord's 10 MB, Telegram's 256 KB and Slack's 5 MB budgets, keeping the best output across attempts so a late encoder failure still returns a file (
ConversionEngine.kt:224) - From-scratch SSRF guard on the URL-inspect Worker: RFC1918/CGNAT/link-local/metadata, IPv4-mapped IPv6, integer-encoded hosts, and 5 redirect hops each revalidated — 11 dedicated tests (
worker/src/ssrf.ts:26) - 136 unit assertions across Kotlin, site and Worker suites, all gating both the Pages deploy and the APK release (
.github/workflows/test.yml,release.yml:59)