New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

gifgif

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

gifgif

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.

latest
Source
npmnpm
Version
0.3.4
Version published
Maintainers
1
Created
Source

gifgif

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:

  • Agent-native, no model inside. It is a CLI, an MCP server, and an HTTP API, driven by the AI session you already have open (Claude Code, Cursor, anything that speaks MCP or can run a shell). There is no model call and no API key anywhere in this tool; the intelligence is the caller's.
  • A link means the file is done. Nothing hands out a URL to a placeholder that improves later. Progress streams to the caller while it waits; the link arrives with the final file.
  • Claims are measured. Every encoder change is judged on a benchmark corpus with a perceptual score, and the comparison against gifski and the ffmpeg + gifsicle pipeline runs on the same hardware from a workflow in this repo (see Where it stands).
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

  • Destination-aware optimization. --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.
  • Edits without a GUI. Trim, crop (named regions, sizes, or percentages), speed, loop, reverse, boomerang, rotate, flip, pad to an aspect ratio or size, captions, palette, dither. Blur or pixelate regions (a token, an email address), push in on a region (the dialog, not the whole desktop), and lay an image over the frame (a logo, a cursor highlight). Several clips join end to end in one command, each with its own stretch (a.mov:2-8 b.mov).
  • Video keeps its sound. MP4 output carries the source's audio track through trims, speed changes, reverse, and boomerang; --mute drops it. GIF and WebP have none.
  • Frames an agent can look at. gifgif frames writes a contact sheet with a timestamp legend, so an assistant can pick "the part where the modal opens" by actually looking.
  • Sharing. Files get a direct link with immutable caching, a share page with Open Graph tags, and paste-ready markdown and HTML.
  • Hosted optimize for machines without ffmpeg. 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.
  • Background jobs. --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.
  • A browser editor on the share server for the people who do not live in a terminal: drop a recording or several clips to join, pick a destination, trim, crop, blur or pixelate a region, zoom in, keep or drop the sound, get the link.
  • Three surfaces, one code path. CLI, MCP server (gifgif mcp), and HTTP API.

Where it stands

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.

EncoderSizeErrorOne coreHosted (4 vCPU)
gifgif 0.2.4, fast search to the 5 MB README budget (the hosted default), 390×610 at 12 fps4.61 MB0.004733 s12.5 s
gifgif 0.2.2, the same search, 344×538 at 10 fps4.24 MB0.007732 s15 s (25 s on a slow host)
gifgif 0.2.2, normal search4.24 MB0.007767 s35 s
gifgif, one encode with settings known4.65 MB0.01108.7 s~4 s
gifski 1.34 Q90, same frames7.06 MB0.005927 s
gifski 1.34 Q70, same frames2.78 MB0.008517 s
ffmpeg palettegen, one shot8.08 MB0.01167 s
ffmpeg + gifsicle walked down to the budget (ezgif by hand)4.29 MB0.0757220 s

What that says, honestly:

  • On this single clip, quality per byte is close to gifski. Fed the same frames, gifski was about 5–7% better than gifgif 0.2.2 at equal size on this clip; 0.2.3's per-frame palettes close part of that on screen recordings. gifski's remaining edge on shaded video comes from re-quantizing whole frames with dithering, which does not compose with the block stabilizer that gives gifgif its size advantage.
  • Per encode it is ffmpeg-fast. One gifgif pass costs about what ffmpeg's palette pass costs and is 2–3× faster than gifski on the same core.
  • Against the pipeline people actually use, it is not close. At the same size the conventional walk has ten times the error and takes seven times longer, because it reaches the budget by destroying the palette instead of choosing the right frame size.
  • The destination workflow is the distinction. gifski and ffmpeg produce whatever size their knobs produce; the converter sites make a person iterate. gifgif targets the destination's limit against a perceptual quality score, and returns a link.

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).

Install

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.

CLI

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.

--objectiveWhat you get
smallest (default)The smallest file whose measured quality stays within --quality (DSSIM 0.004 by default, below what people notice).
qualityThe best-scoring candidate that fits the budget. Usually a larger file for a difference under the threshold; for hard clips when bytes are free.
fastestThe 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.

Use it from Claude Code

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.

Use it from GitHub Actions

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).

Web editor

/ 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.

Share server

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.

2. Docker or Railway

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.

3. Plain Node

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.

HTTP API

MethodPathPurpose
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/projectsCloudflare 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/tokensCloudflare 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=1Cloudflare only, master token. List tokens, with today's usage.
DELETE/api/admin/tokens/:id?purge=1Cloudflare only, master token. Revoke a token; purge=1 also deletes every file it stored.
GET/api/meAny 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/invitesCloudflare only, master token. Body {"count": 5, "limits": {...}, "name": "beta"}. Returns single-use invite codes. GET lists them with redeemed status.
POST/api/signupOff 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/plansThe plans and their limits, and whether signup is open.
POST/api/tokens/redeemNo 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}.gifThe 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}/videoLink 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}/playerThe 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/targetsDestination presets.
GET/healthLiveness, and whether optimize is available.
GET/statusThe last synthetic check (every 30 minutes): whether the optimizer answered, its probe and clock.
POST/api/admin/sweepMaster 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

How the pieces fit

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.

Destinations

idbudgetwidthfpsnote
github-readme5 MB96012READMEs only animate GIFs
github-pr10 MB100015attachment limit 10 MB
slack3 MB80012big GIFs show as a thumbnail
discord10 MB80015free-tier limit
notion5 MB120012free-plan limit
email1 MB60010
twitter5 MB8001515 MB web, 5 MB mobile
linear10 MB100015
jira10 MB100012default attachment limit
web5 MB80015general default
web-video5 MB128030MP4 with a poster, for pages that play video

Budgets are practical targets, not the platforms' hard ceilings. Override with --max.

How the optimizer works

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).

Native encoder (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).

  • Analyze. Sample a few native-resolution frame pairs and measure colour count, flatness, edge density, motion, and the codec noise floor. Palette sources and pixel art are detected as exact-colour content and encoded without any loss. No model is involved; it is arithmetic and takes a few hundred milliseconds.
  • Scenes. A thumbnail pass finds hard cuts and fades. Each scene gets its own palette; frames outside the longest scene carry a GIF local colour table.
  • Palette. A native quantizer over sampled frames: an integer colour histogram on a grid uniform in OKLab lightness (exact colours for low-colour content), weighted by an importance map that ranks edges and text above flat fill, split by Wu's variance-minimising cuts and refined by weighted k-means in OKLab, with a pass that moves a colour onto a heavy colour the palette leaves far away. The rare anti-aliasing colours that keep text crisp survive. At most 255 colours plus a transparent index. A frame whose redrawn pixels that palette serves poorly can carry a small local palette built from exactly those pixels, when the change covers at most a quarter of the frame; the canvas is tracked in RGB, so such a frame still leaves everything else transparent. The search tries this once per clip and keeps it only when the encode is at least 5% smaller within the quality target: on screen recordings it is, on shaded video and anti-aliased text the second quantization grid next to held pixels reads as noise and the trial is rejected.
  • Stabilize. Sticky pixels decided per 4×4 block: a block keeps its previous pixels when nothing in it moved more than the local tolerance, which is loose in flat regions and tight on edges. This turns codec noise into genuinely unchanged pixels, which is where most of the size goes, without salt-and-pepper.
  • Coherent remap. Nearest colour in OKLab through a lookup table, keeping the previous frame's index whenever it is nearly as good. Screen-aligned ordered dither is available as a fallback when a smaller palette would otherwise miss the quality target.
  • Encode. Unchanged frames extend the previous delay; changed frames are cropped to their bounding box with unchanged pixels inside marked transparent. Delays carry a rounding accumulator so timing stays exact.
  • Score and search. The result is decoded in lockstep with the source and compared with DSSIM (0 = identical; under 0.01 is hard to notice) plus a temporal term that penalises motion the source did not have, in excess of the zero-tolerance floor. The search measures that floor, bisects the tolerance, reduces the palette (with dither as a fallback), and widens the coherence slack while the objective stays under the target. On noise the stabilizer cannot hold (tolerance 0, as on camera video), it also tries lossy runs and keeps them for a 10% saving: they buy bytes far more cheaply than dropping colours, which is what bands smooth gradients. On clips of 25 s or more this quality phase runs on four evenly spaced windows of the clip. If a byte budget is still not met, a byte model (bytes scale with pixel count, frame rate, and palette size, refit from every encode) predicts which resolution and frame-rate rung fits at each palette size; lower rungs are derived in memory from the decoded top rung, over-budget probes stop the moment they pass the budget, and every fitting candidate is scored against the top-rung reference so the best one wins. Fast effort takes the single predicted rung and climbs while the fit is loose; normal and high compare candidates. When the best fit took a smaller palette at a rung where a larger one was just over, the size between them is probed there once. Every attempt is reported.

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

ffmpeg + gifsicle chain

  • Probe the source. Resolve the crop, the base width (target width or source width, whichever is smaller), and the base fps.
  • Walk a ladder of (width, fps) rungs: full size, then 12 fps, then 85%, 75%, 60%, 50%, 40% width with lower fps. Pinned --width or --fps freezes that axis.
  • At each rung, render with ffmpeg using a per-clip palette (palettegen with stats_mode=diff) and paletteuse with rectangle diffing, which only re-encodes changed regions.
  • Then run gifsicle -O3 through palette and lossy steps: 256 colors lossless, then lossy 30, 128 colors, lossy 60, 64 colors, lossy 100, 32 colors lossy 140.
  • Stop at the first result under budget. If nothing fits, keep the smallest and say so in 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.

Benchmark

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.

Development

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

Limitations

  • GIF is the wrong format wherever the destination plays video. --format mp4 or webp go through ffmpeg with a quality-scored search across CRF and size, but the native encoder itself is GIF-only.
  • Per-frame palettes help screen recordings and are rejected by measurement on shaded video and anti-aliased text; gifski's remaining edge there (whole-frame re-quantization with dithering) does not compose with the block stabilizer. The quality target is not yet calibrated against human judgement.
  • Hosted speed depends on which machine the container lands on; the same job has measured 1.6× apart between hosts on one day. With 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.
  • Captions need ffmpeg's drawtext filter (freetype), which Homebrew's default build omits; --caption then says so and --remote uses the hosted container, which has it.
  • The CLI needs a 0.2+ binary (--objective); gifgif doctor says when the one it found is older, and GIFs go through the chain until it is replaced.
  • The hosted result cache and in-place retry cover uploads up to 8 MB; a larger upload interrupted by a container restart is re-uploaded by the CLI.
  • Cloudflare enforces user-token quotas and ownership; Node uses a shared bearer token and a two-request admission cap. No content moderation is included.

License

MIT

Keywords

gif

FAQs

Package last updated on 27 Sep 2026

Related posts