
Company News
Socket Joins New OpenJS Program to Fund Node.js Security Work
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.
Agent-native GIF optimizer and sharer. CLI, MCP server, and HTTP API that fit any clip into a GitHub README, PR, Slack, Discord, Notion, or email.
The goal: an AI-first, extremely fast, high-quality ezgif. You have a screen recording. It needs to go into a README, a PR, Slack, or Discord, each with its own size limit and its own idea of what plays inline. Today that means a converter site, guessing at width and colours, checking the size, and trying again. gifgif takes "this clip, for a README" and returns a link to a file that fits at the highest quality the budget allows, and it does that for an agent as readily as for a person.
Three commitments follow from that:
npx gifgif optimize demo.mov --for github-readme
# demo.github-readme.gif — 2.9 MB (budget 5.00 MB) ✓ fits
# 960×540 · 12 fps · 72 frames · 6.0s · gif
What it does
--for github-readme|github-pr|slack|discord|notion|email|twitter|linear|jira|web|web-video sets the format, size budget, width, and fps. The search returns the smallest file that still scores within the quality threshold; --objective quality keeps the best-scoring fit instead, and --objective fastest stops at the first fit.a.mov:2-8 b.mov).--mute drops it. GIF and WebP have none.gifgif frames writes a contact sheet with a timestamp legend, so an assistant can pick "the part where the modal opens" by actually looking.gifgif optimize --remote sends the clip to your share server and gets the optimized file and links back; info and frames do the same. It happens automatically when ffmpeg is missing and a server is configured.--async submits the clip and returns a job id. The link arrives when the file is done, and a closed laptop does not lose it.gifgif mcp), and HTTP API.Measured on a 30-second 460×720 screen recording at the default objective, the smallest file within the quality threshold. Since 0.2.3 the search can give frames their own palettes (see how the optimizer works); on this clip that lets the 5 MB budget fit 390×610 at 12 fps instead of 344×538 at 10 fps, 39% less error at the same size. The "one core" column is every encoder pinned to one x64 core with AVX2, the same hardware for all (the workflow is .github/workflows/bench-compare.yml; the 0.2.3 row ran on 2026-09-11, the rest on 2026-09-09); the "hosted" column is gifgif's end-to-end time on the deployed 4 vCPU container, measured on 2026-09-12 (hosts vary by up to 40%; this was a normal one). Output 344×538 at 10 fps unless noted. Error is DSSIM against the source, lower is better; under 0.01 is hard to notice.
| Encoder | Size | Error | One core | Hosted (4 vCPU) |
|---|---|---|---|---|
| gifgif 0.2.4, fast search to the 5 MB README budget (the hosted default), 390×610 at 12 fps | 4.61 MB | 0.0047 | 33 s | 12.5 s |
| gifgif 0.2.2, the same search, 344×538 at 10 fps | 4.24 MB | 0.0077 | 32 s | 15 s (25 s on a slow host) |
| gifgif 0.2.2, normal search | 4.24 MB | 0.0077 | 67 s | 35 s |
| gifgif, one encode with settings known | 4.65 MB | 0.0110 | 8.7 s | ~4 s |
| gifski 1.34 Q90, same frames | 7.06 MB | 0.0059 | 27 s | |
| gifski 1.34 Q70, same frames | 2.78 MB | 0.0085 | 17 s | |
| ffmpeg palettegen, one shot | 8.08 MB | 0.0116 | 7 s | |
| ffmpeg + gifsicle walked down to the budget (ezgif by hand) | 4.29 MB | 0.0757 | 220 s |
What that says, honestly:
Short clips are quick everywhere: a 4-second clip takes about 6 s hosted end to end and 1 s on a laptop. The physical floor for the 30-second clip is decode plus one encode plus one score, about 2.6 s on a laptop and 4.5 s hosted; everything above that is search, and the search is where the remaining speed work lives. npm run bench:repro -- --compare reruns a smaller version of this comparison on generated fixtures (bench/repro/README.md).
Requirements: Node 24+. For local optimization: ffmpeg (with ffprobe) and gifsicle (optional, but makes GIFs much smaller). Without ffmpeg the CLI still works against a share server.
brew install ffmpeg gifsicle # macOS
# apt install ffmpeg gifsicle # Debian/Ubuntu
npm install -g gifgif # or: npx gifgif optimize demo.mov --for slack
gifgif doctor
From a clone instead:
git clone <this repo> gifgif && cd gifgif
npm install # dev tooling only; the CLI has no runtime dependencies
npm link # puts `gifgif` on your PATH
A clone runs the TypeScript source directly on Node 24; the published package is the compiled build (npm run build produces it, and npm pack runs that first).
The native encoder is not in the package: it is a prebuilt binary for macOS (arm64, x64), Linux (x64, arm64), and Windows (x64), and npm install fetches the right one after SHA-256 verification; gifgif setup does the same on demand, and gifgif doctor shows whether it is in place. Without it, GIFs go through the ffmpeg + gifsicle chain. Every release's binaries are published to https://gifgif.dev/dl/<tag>/<asset> (/dl/latest names the current one), which needs no credentials; the GitHub release is the fallback (set GITHUB_TOKEN from a private clone), and GIFGIF_DOWNLOAD_BASE points the installer at another mirror. Publishing a release's binaries is node scripts/publish-binaries.mjs vX.Y.Z --latest with a wrangler login. The x64 binaries need a CPU with AVX2 (Intel from 2013, AMD from 2015); on anything older, build it yourself. Building it yourself needs Rust: cd enc && cargo build --release.
gifgif optimize <file> --for <target> [options]
gifgif optimize a.mov:2-8 b.mov c.mov --for <target> # joined end to end, then the recipe applies (on the server when ffmpeg is not here, or with --async)
gifgif optimize <file> --for <target> --async # hosted background job; returns a job id
gifgif job <id> [--cancel] # its status and, when done, the links
gifgif info <file> [--json] [--remote]
gifgif frames <file> [--count 12] [--cols 4] [--at <time>] [--json] [--remote]
gifgif share <file> [--title ...] [--alt ...] [--expires 7d]
gifgif links # the links your token made, newest first
gifgif edit <link> [--title ...] [--alt ...] [--expires 1d|7d|30d|90d|never]
gifgif delete <link> [<link>...]
gifgif targets
gifgif login # sign in through your browser (or --invite inv_... on invite-only servers)
gifgif config --server https://gifs.example.com --token SECRET
gifgif doctor
Optimize options: --out, --max 5mb, --format gif|mp4|webp, --objective smallest|quality|fastest, --quality <dssim>, --effort fast|normal|high, --trim 2-8.5, --start, --end, --crop left-half|WxH|WxH+X+Y|x,y,w,h, --width, --height, --fps, --speed 1.5, --loop n, --once, --reverse, --boomerang, --rotate, --flip, --pad, --border, --corners, --transparent, --caption (repeatable, with a place and a time range: "Hello @top 2-5"), --brightness, --contrast, --saturation, --grayscale, --fade-in, --fade-out, --blur, --pixelate, --zoom, --overlay, --mute, --colors, --lossy, --dither bayer|sierra2_4a|floyd_steinberg|none, --engine auto|chain, --share, --expires 1d|7d|30d|90d|never (how long the link lives; free links last at most 90 days), --remote, --async, --title, --alt, --json, --quiet.
Every command accepts --json for machine-readable output. Progress goes to stderr; results go to stdout.
Regions for --blur, --pixelate, and --zoom use the crop syntax in source pixels (200x40+30+90, top-right, 10%,5%,30%,8%), so coordinates read off gifgif frames apply directly; --blur and --pixelate repeat, an optional :N sets the radius or block size, and --zoom region:seconds pushes in over that many seconds (default 1.5) and holds. --overlay logo.png@bottom-right:15% places an image (corners, center, or x,y; width in pixels or percent); the image has to be on the machine that encodes, so it is a local-only edit. --caption repeats; each caption takes a place (@top, @bottom-left, @40%,10%) and a time range last ("Hello @top 2-5", seconds or m:ss, an open end runs to the clip's end). --brightness, --contrast and --saturation adjust the colour (--grayscale drops it), --fade-in and --fade-out fade from and to black over that many seconds of the finished clip, and --border 8,white frames the picture. --corners 24 rounds the corners and --transparent "#00ff00:24" keys a colour out: both see through on GIF and WebP (the native encoder keeps the corners as a static mask so frames stay deltas, and draws keyed clips frame by frame so a pixel can turn transparent again) and show black on MP4, which has no transparency. The same edits are query parameters on the API (caption repeats; brightness, contrast, saturation, grayscale=1, fadeIn, fadeOut, border, corners, transparent) and controls in the editor.
--remote optimizes on the share server. The result is stored there and the links come back; add --out path to also download the file. When the recipe trims a video and ffmpeg is on this machine, only the requested stretch is uploaded (cut locally at a visually lossless setting), so a 44-second recording trimmed to six seconds is a six-second upload. When ffmpeg is not installed and a server is configured, optimize switches to remote on its own and says so. info and frames do the same, so an agent on a machine without ffmpeg can still look at a clip before choosing trim points.
Objectives.
--objective | What you get |
|---|---|
smallest (default) | The smallest file whose measured quality stays within --quality (DSSIM 0.004 by default, below what people notice). |
quality | The best-scoring candidate that fits the budget. Usually a larger file for a difference under the threshold; for hard clips when bytes are free. |
fastest | The first fit, at fast search effort. |
The search is bounded, not a proof of optimality, and --quality is a perceptual threshold, not a guarantee of human preference. Explicit --width, --height, and --fps pin those axes. Without the native binary the ffmpeg chain takes the first fitting setting and says so; smallest needs the native scorer.
Background jobs. --async uploads the clip to the share server and returns a job id instead of waiting:
gifgif optimize demo.mov --for github-readme --async --json
gifgif job JOB_ID --json
gifgif job JOB_ID --cancel
The id is not a link. gifgif job reports queued, then running with the current search step, then complete with the final links, or failed with the reason. Status is private to the token that submitted the job. Jobs have a five-minute deadline, survive a closed laptop and a server restart, and are kept for 24 hours; the mechanics are in docs/projects.md. Ctrl-C cancels a synchronous run and kills the encoder; MCP clients get the same through cancellation notifications.
As a plugin (adds the gif_* tools and a workflow skill):
claude plugin add /path/to/gifgif
As a plain MCP server in any project (no clone needed):
claude mcp add gifgif -- npx -y gifgif mcp
Or just the CLI. The skill in skills/gifgif/SKILL.md tells an agent how to use it; copy it into your project's .claude/skills/ if you are not using the plugin.
For Cursor, Windsurf, or any MCP client, the server config is:
{ "mcpServers": { "gifgif": { "command": "npx", "args": ["-y", "gifgif", "mcp"] } } }
It is also listed in the MCP registry as io.github.alexmeckes/gifgif.
MCP tools: gif_targets, gif_info, gif_frames (returns an image), gif_optimize (path or paths to join several; returns a contact sheet of the output; remote: true for machines without ffmpeg, async: true for a background job), gif_job, gif_share, gif_links, gif_update (title, alt text, lifetime), gif_delete, gif_doctor; gif_optimize and gif_share take expires. Tool calls report progress when the client sends a progress token, and honour cancellation notifications.
alexmeckes/gifgif is also an action. Given a recording, it optimizes it on your share server and posts (or updates) a comment on the pull request with the link:
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: alexmeckes/gifgif@v0.2.4
with:
file: demo.mp4 # from a Playwright run, a checked-in recording, an artifact...
for: github-pr
server: ${{ vars.GIFGIF_SERVER }}
token: ${{ secrets.GIFGIF_TOKEN }}
args: --trim 0-8
Outputs url, page, markdown, and bytes for other steps. Set comment: "false" to skip the PR comment. The token should be a user token minted for CI (see Tokens and quotas).
/ on the share server is a single-page editor for the people on a team who do not live in a terminal: drop a recording, choose the destination, trim and crop, watch the search, then copy the Markdown or download the file. It calls the same authenticated API as the CLI; the token stays in page memory, and an invite code can be redeemed right there. On Cloudflare a recording is uploaded once and reused for an hour of edits, edits can be saved as a named project (with or without keeping the original private for 30 days) and exported for any of the destinations in one go, each at its own budget, size and format, and any recipe downloads as a small JSON file that imports anywhere without a token. The details and the projects API are in docs/projects.md.
Three ways to run one. All three speak the same HTTP API, so the CLI does not care which you pick.
Containers need the Workers Paid plan; on the free plan wrangler deploy builds the image and then fails with a 401 on the container registry. The Worker and R2 sharing work on either plan.
Files live in R2 (durable, no egress fees, cached at the edge). The share pages and API run in a Worker. Server-side optimize runs in a Container built from the repo's Dockerfile (Node 24 + ffmpeg + gifsicle) that sleeps when idle, so it costs nothing until someone uses it.
Requirements: a Cloudflare account, the Workers Paid plan for the Container (about $5/month, with generous included usage), and Docker running locally for wrangler deploy to build the image. Alternatively connect the repo to Workers Builds, which builds the image in Cloudflare's CI.
npm install
npx wrangler login
npx wrangler r2 bucket create gifgif
npx wrangler secret put GIFGIF_SHARE_TOKEN # paste a long random string; uploads require it
npx wrangler deploy # builds and pushes the image, deploys the Worker
Upgrading from 0.1: the Worker now has four Durable Object classes (quotas, jobs, sources, projects) and wrangler deploy applies their migrations; deploy the Worker and the container together, since a 0.1 optimizer does not know objectives or jobs.
The first deploy takes several minutes while the container is provisioned. Later deploys do not replace a running instance until it stops; roll it with curl -X POST -H "authorization: Bearer SECRET" https://gifgif.<your-subdomain>.workers.dev/api/admin/optimizer/restart (a job in flight at that moment gets a 503 and is retried once by the Worker). Then point the CLI at it:
gifgif config --server https://gifgif.<your-subdomain>.workers.dev --token SECRET
gifgif optimize demo.mov --for slack --remote
Tokens and quotas (Cloudflare). The deployment's own token (GIFGIF_SHARE_TOKEN) is the master: unlimited, and the only one that can mint others. Give each person or agent a user token instead: POST /api/admin/tokens returns a gg_… token with per-minute, per-day, and bytes-per-day limits (defaults 10 / 200 / 2 GB). A user token can only delete the files it stored, sees its own usage at /api/me, and gets a 429 with retry-after when a limit is hit; cache hits cost nothing. Revocation takes effect within a minute. Counters live in a Durable Object per token, so they are exact under load. To let people sign up without you minting tokens by hand, create invite codes (POST /api/admin/invites) and send them; gifgif login --server URL --invite CODE redeems one and saves the token.
Use your own domain. Links pasted into READMEs should outlive the workers.dev hostname. Add a custom domain to the Worker in the Cloudflare dashboard (Workers & Pages → gifgif → Settings → Domains & Routes) and set PUBLIC_URL in wrangler.jsonc to https://gifs.example.com so returned links use it.
No Docker on this machine? npx wrangler deploy --containers-rollout=none deploys the Worker alone; sharing works, and /api/optimize answers 503 until the container exists. Or push the repo to GitHub and connect it in Workers & Pages → Builds with the deploy command npx wrangler deploy.
Free plan, or no container wanted? npm run deploy:lite deploys the lite environment: Worker + R2 only. Hosted optimize can still work by pointing OPTIMIZER_URL (in wrangler.jsonc under env.lite.vars) at any gifgif serve you run elsewhere, such as the Railway setup below, with npx wrangler secret put OPTIMIZER_TOKEN --env lite if that server requires one.
Local development.
cp .dev.vars.example .dev.vars # sets a local GIFGIF_SHARE_TOKEN
npm run serve # the optimizer on :8787
npm run dev:worker # the Worker on :8788, talking to the optimizer above
wrangler dev --env lite uses a local R2 simulation and talks to the Node optimizer over OPTIMIZER_URL, so the whole flow runs without Docker or an account.
Tuning knobs in wrangler.jsonc: instance_type (a custom 4 vCPU / 12 GiB instance; Cloudflare bills container CPU on active usage and requires 3 GiB per vCPU, so more cores cost the same per job and finish sooner, and standard-2 = 1 vCPU / 6 GiB halves the idle memory cost if that matters more), max_instances and OPTIMIZER_INSTANCES (parallel optimize capacity; each instance runs two jobs at once), MAX_UPLOAD (Cloudflare caps request bodies at 100 MB on Free and Pro zones), FREE_JOBS_PER_DAY (the whole free tier's daily ceiling, all tokens together, 500; a circuit breaker so an abusive burst is a bounded cost and a bounded queue; 0 disables), CONTACT_EMAIL (where takedown and abuse reports go; shown on the landing and share pages when set), OPTIMIZER_KEEP_WARM ("1" pings the optimizer every two minutes so the first job after a quiet spell skips the cold start, about 2 s measured after a hard restart and up to 10 s when the platform pulls the image first; the container then bills its memory all day, about $2.60 for the 12 GiB instance), OPTIMIZER_SLOW_MS (the host check: after a fresh start the container times a fixed encode while idle and reports it on /health; when that reads slower, the next request moves the instance to another machine, at most three times an hour; 0 only reports), and OPTIMIZER_MIN_MHZ (the same move for a host whose clock, read from /proc/cpuinfo, is under this; a 2.0 GHz machine ran jobs 30% slower than the 2.55 GHz ones while its probe read normal; 0 disables). The container sleeps 3 minutes after its last job.
The repo ships a Dockerfile (Node 24 + ffmpeg + gifsicle) and railway.json. Create a service from the repo, attach a volume mounted at /data, set GIFGIF_SHARE_TOKEN, and generate a public domain. Any Docker host works the same way. Files live on the volume, so back it up.
gifgif serve --port 8787 --data ./data [--token SECRET] [--public-url https://gifs.example.com]
Environment variables: PORT, DATA_DIR, GIFGIF_SHARE_TOKEN, PUBLIC_URL, GIFGIF_MAX_UPLOAD. On Railway, RAILWAY_PUBLIC_DOMAIN is picked up automatically.
| Method | Path | Purpose |
|---|---|---|
POST | /api/jobs?for=&objective=&... | Submit a background optimization; returns 202 with an ID and status URL. source=ID,ID,… (Cloudflare) joins several private uploads end to end first; clips=START-END,,START-END trims each. |
POST | /api/stage?name= | Keep a clip on the optimizer for 15 minutes and return its id. A request to /api/optimize, /api/jobs, /api/info or /api/frames with staged=ID,ID,… and no body joins them end to end, then applies the recipe; clips= trims each. How the CLI sends several clips from a machine without ffmpeg. |
GET | /api/jobs/{id} | Owner/master only: progress, error, or completed result. |
DELETE | /api/jobs/{id} | Cancel a queued/running job; terminal jobs are unchanged. |
POST | /api/info?name= | Upload a source for metadata; source discarded after inspection. |
POST | /api/frames?name=&count=&cols=&at=&start=&end= | Contact sheet or single frame as base64 JPEG, with timestamp legend and source metadata. |
POST | /api/sources?name= | Cloudflare only. Keep a private upload for an hour and return its id; pass source=ID instead of a body to /api/jobs, /api/info, /api/frames, or /api/projects. GET and DELETE /api/sources/{id} for the owner. |
POST | /api/projects | Cloudflare only. Saved edits, optional 30-day originals, exports for any destination: docs/projects.md. |
POST | /api/upload?title=&alt=&name=&width=&height= | Store a finished gif/mp4/webp (raw body, content-type and content-length headers). Returns url, page, markdown, html. |
POST | /api/optimize?for=&trim=&crop=&... | Upload any video or GIF; the server optimizes it and stores the result. Same query names as the CLI flags (audio=0 drops the sound on MP4 output). Returns the links plus the full optimize result. Sending the same bytes with the same query again returns the stored result at once (cached: true; the Worker holds uploads up to 8 MB in memory for this and for one retry after a container restart, larger uploads stream through). With accept: application/x-ndjson the response streams one JSON line per search step, then a result line (the CLI does this for --remote). |
POST | /api/admin/optimizer/restart?hard=&instances= | Cloudflare only. Stop the optimizer container(s) so the next request starts fresh on the current image. Run it after wrangler deploy: a running instance keeps the previous image until it stops. |
POST | /api/admin/optimizer/check?max= | Cloudflare only. Time the fixed probe encode on the container's host now and restart it when that takes longer than max ms (default OPTIMIZER_SLOW_MS). Also reports the container's idle reading and how many times the instance has been moved this hour. |
POST | /api/admin/tokens | Cloudflare only, master token. Body {"name": "ci", "limits": {"perMinute": 10, "perDay": 200, "bytesPerDay": 2147483648}} (limits optional). Returns the new gg_… token once. |
GET | /api/admin/tokens?usage=1 | Cloudflare only, master token. List tokens, with today's usage. |
DELETE | /api/admin/tokens/:id?purge=1 | Cloudflare only, master token. Revoke a token; purge=1 also deletes every file it stored. |
GET | /api/me | Any token. Its id, limits, today's usage, and when the day resets. |
GET | /api/me/links?cursor= | Any token. The links it made, newest first, 50 a page: page and file URLs, size, title, created, expires. |
PATCH | /api/g/{id} | Owner or master. JSON {"title", "alt", "expires"}; expires is 1d, 7d, 30d, 90d or never, capped at the plan's retention. The file itself never changes. |
POST | /api/admin/invites | Cloudflare only, master token. Body {"count": 5, "limits": {...}, "name": "beta"}. Returns single-use invite codes. GET lists them with redeemed status. |
POST | /api/signup | Off unless SIGNUP_OPEN=1 (off on gifgif.dev, where gifgif login or a token from the Library's Agent and terminal tab replaces it). No token needed. Body {"email": "you@example.com"}. Returns a free-plan token, shown once; one per address, capped per IP per hour (SIGNUP_PER_HOUR). The address is a contact, not a credential. |
GET | /api/plans | The plans and their limits, and whether signup is open. |
POST | /api/tokens/redeem | No token needed. Body {"invite": "inv_…", "name": "me"}. Turns an invite into a user token (shown once). The CLI does this with gifgif login --server URL --invite CODE. |
GET | /f/{id}.gif | The file. Immutable cache headers, CORS, range requests. |
GET | /g/{id} | Share page with Open Graph tags and copyable snippets. |
GET | /api/g/{id} | Metadata as JSON. |
DELETE | /api/g/{id} | Remove a share (bearer token). |
PUT | /api/g/{id}/poster, /api/g/{id}/video | Link previews for a file uploaded without them: a PNG still (up to 5 MB) and, for a GIF, its MP4 twin (up to 25 MB). Owner or master token. The CLI does this by itself after --share; optimize results get both from the optimizer. |
GET | /g/{id}/player | The bare player that X player cards and oEmbed embeds frame. |
GET | /oembed?url= | oEmbed 1.0 for a share or file link: a photo for GIFs and stills, a video player for MP4. Share pages advertise it. |
GET | /api/targets | Destination presets. |
GET | /health | Liveness, and whether optimize is available. |
GET | /status | The last synthetic check (every 30 minutes): whether the optimizer answered, its probe and clock. |
POST | /api/admin/sweep | Master token. Remove free-plan links past their retention now (the cron does this daily at 04:13 UTC). |
Uploads need authorization: Bearer <token>. The Cloudflare Worker refuses uploads until a token is set; the Node server runs open when started without one, so put a token on anything reachable from the internet.
Share pages and files carry noindex (a robots meta tag and an X-Robots-Tag header), so shared recordings stay out of search results; robots.txt is not used for this because X's preview crawler obeys it. /api/upload, /api/optimize and /api/jobs take expires= for a shorter-lived link. A client that encoded a file itself can send its optimize summary in an x-gifgif-result header (base64url JSON; the CLI does) so the page shows the limit it fits and its measured quality; fit is recomputed from the stored size.
Share pages carry link-preview tags for every platform that reads them: Open Graph with a PNG poster, og:video pointing at a direct MP4 (for a GIF, its MP4 twin, usually a fraction of the GIF's size), a Twitter player card, and oEmbed discovery. Discord, iMessage and Telegram play the video in the preview; Slack shows the poster card (it only plays video from providers it has registered); X plays it once the domain's player cards are approved in X's developer portal. A GIF up to 5 MB is its own preview image; a bigger one previews as its poster.
Usage analytics are server-side only: the Worker writes one Workers Analytics Engine row per event (optimize, upload, link edits and deletes, sign-ups, page views, file loads by where they came from, such as GitHub or Slack), with no scripts, cookies or IP addresses. Clients name themselves in x-gifgif-client (cli, mcp, editor, action). node scripts/analytics.mjs [days] summarises it with an API token that can read account analytics.
Links made with a free-plan token are removed LINK_RETENTION_DAYS_FREE days after they are made (90; the share page and /api/g/{id} say when); links from every other token are kept. The index behind this is one R2 key per expiring file, so the daily sweep lists a prefix rather than every file.
Two master-token parameters exist for measuring encoder changes on one hosted machine instead of across the host lottery: encoder=<name> runs the request through an alternate build installed on the optimizer (GIFGIF_ENC_<NAME>, added to the image for the experiment) and encArgs=<flags> appends flags to the encode call, for example encArgs=--palette-reuse%200 to measure a default against its opposite.
curl -X POST "https://gifs.example.com/api/optimize?for=github-readme&trim=2-8" \
-H "authorization: Bearer SECRET" -H "content-type: video/mp4" --data-binary @recording.mp4
CLI / MCP ──optimize locally──▶ file ──/api/upload──▶ Worker ──▶ R2 ──▶ /f/{id}.gif
CLI / MCP ──/api/optimize (no ffmpeg here)──▶ Worker ──▶ Container (gifgif serve + ffmpeg)
│◀── result ──┘
└──▶ R2, then the container's scratch copy is deleted
The Worker streams uploads into R2 using their declared length and file responses out of R2 through the edge cache; the only bodies it holds in memory are optimize uploads up to 8 MB, two at a time per isolate, for the result cache and one retry. The container only ever holds a job's input and output for the seconds it takes to copy the result to R2.
| id | budget | width | fps | note |
|---|---|---|---|---|
| github-readme | 5 MB | 960 | 12 | READMEs only animate GIFs |
| github-pr | 10 MB | 1000 | 15 | attachment limit 10 MB |
| slack | 3 MB | 800 | 12 | big GIFs show as a thumbnail |
| discord | 10 MB | 800 | 15 | free-tier limit |
| notion | 5 MB | 1200 | 12 | free-plan limit |
| 1 MB | 600 | 10 | ||
| 5 MB | 800 | 15 | 15 MB web, 5 MB mobile | |
| linear | 10 MB | 1000 | 15 | |
| jira | 10 MB | 1000 | 12 | default attachment limit |
| web | 5 MB | 800 | 15 | general default |
| web-video | 5 MB | 1280 | 30 | MP4 with a poster, for pages that play video |
Budgets are practical targets, not the platforms' hard ceilings. Override with --max.
There are two engines. gifgif optimize uses the native one for GIF output whenever its binary is built, and falls back to the ffmpeg chain otherwise (or with --engine chain, --lossy, a dither other than none, or a finite --loop count).
enc/, Rust)A first-principles GIF encoder that owns every pixel between ffmpeg's decoder and the file. It streams: at most a few frames are in memory at once, so a two-minute recording costs the same memory as a ten-second one (about 400 MB, most of it DSSIM scratch).
Knobs on the CLI: --quality <dssim> (default 0.004; 0.002 is stricter, 0.01 looser), --effort fast|normal|high (fast is about twice as quick and usually lands on the same result; it is the hosted default), --engine auto|chain, and --objective smallest|quality|fastest (smallest is the default; quality keeps the best-scoring fit, fastest the first fit). On the binary, --palette auto|scene|frame frees or pins the per-frame palette choice. On the binary: --effort fast|normal|high, --max-bytes, --colors, --stabilize, --tau-edge, --slack, --dither, --lossy (by default probed only when the tolerance stays at 0, as on noisy video, and kept for a 10% saving; elsewhere it measured as a poor trade), --scenes 0|1, --temporal, --view-scale 0.5 (score as if shown at half size), --memory-mb (frame cache budget; 0 streams everything), and GIFGIF_SCORE_THREADS (DSSIM concurrency, default 4, about 100 MB each).
On the benchmark corpus at the default target the native encoder is 1.5 to 6 times smaller than the chain for screen recordings and game footage, and the reported DSSIM tells you what it cost. On a laptop a 30-second recording takes about 4.5 s at fast effort including the search, and about 12.5 s end to end on the hosted 4 vCPU container; a single encode with the settings already known takes 2.6 s and about 4.5 s respectively.
cd enc && cargo build --release # needs Rust; the binary is found automatically
cargo test --release # round-trip, timing, and stabilizer tests
enc/target/release/gifgif-enc analyze demo.mov # signals and scene cuts
enc/target/release/gifgif-enc encode demo.mov -o demo.gif --width 800 --fps 15 --max-bytes 3000000 --effort high
--width or --fps freezes that axis.palettegen with stats_mode=diff) and paletteuse with rectangle diffing, which only re-encodes changed regions.-O3 through palette and lossy steps: 256 colors lossless, then lossy 30, 128 colors, lossy 60, 64 colors, lossy 100, 32 colors lossy 140.notes.MP4 output walks CRF 18 to 35 and WebP quality 90 to 30 across the same width rungs. When the native encoder's scorer is available, the first fit at a rung and the first fit one rung down are both scored against the top-rung source (gifgif-enc score out.mp4 src --ref-width W) and the better one wins, and the result carries the DSSIM like a GIF does. WebP needs an ffmpeg built with libwebp.
bench/ holds a corpus manifest, a script that cuts excerpts from local source files, and runners:
sh bench/make-corpus.sh # cut excerpts into bench/corpus/ (git-ignored)
node bench/baseline.ts --dither none # the ffmpeg chain at a fixed setting
node bench/enc-bench.ts # native encoder variants vs the chain, scored with DSSIM
node bench/signals.ts # the content signals per clip
.github/workflows/bench-compare.yml (run it from the Actions tab) encodes the same clip with gifgif, gifski, ffmpeg's palette pass, gifsicle, and the ffmpeg + gifsicle budget walk, each pinned to one x64 core, and scores every output with the same metric. The table in Where it stands comes from it. Two rules that came out of building it: no encoder change lands without an A/B on the corpus, and tools are only compared on the same frames, because the scorer favours output built from the reference's own frames.
The search gate (test/search-gate.test.ts, run by CI on the 30-second bench clip) asserts per objective that the quality phase ran on the excerpt, that the step count stays bounded, and that the result is the known one for the CPU family: the check that would have caught the 0.2.0 slowdown. Three more runners: npm run bench:repro regenerates six deterministic fixtures and gates every objective on byte budget and quality (CI runs it and uploads a blind A/B sheet), bench/performance/run.ts times 10 to 60 second recordings you keep locally, and bench/production/run.ts submits a bounded batch of jobs to a deployment and records every timing stage.
npm test # unit tests
npm run typecheck # Node code and the Worker
GIFGIF_TEST_FFMPEG=1 node --test test/encoding.test.ts # real-media integration (build Rust first)
npm run bench:repro # regenerated corpus, regression gates, and blind A/B review
GIFGIF_GATE_CLIP=bench-spruce30.mp4 node --test test/search-gate.test.ts # the long-clip search gate (CI runs it)
npm run types # regenerate worker-configuration.d.ts after editing wrangler.jsonc
--format mp4 or webp go through ffmpeg with a quality-scored search across CRF and size, but the native encoder itself is GIF-only.OPTIMIZER_SLOW_MS set, the container times a fixed encode when idle after a start and the next request moves it off a slow host; POST /api/admin/optimizer/check does it on demand.drawtext filter (freetype), which Homebrew's default build omits; --caption then says so and --remote uses the hosted container, which has it.--objective); gifgif doctor says when the one it found is older, and GIFs go through the chain until it is replaced.MIT
FAQs
Agent-native GIF optimizer and sharer. CLI, MCP server, and HTTP API that fit any clip into a GitHub README, PR, Slack, Discord, Notion, or email.
We found that gifgif demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.