Skip to content

fix: keep every frame when optimizing animated images - #168

Merged
rsbh merged 1 commit into
mainfrom
fix/animated-gif-frames
Aug 5, 2026
Merged

fix: keep every frame when optimizing animated images#168
rsbh merged 1 commit into
mainfrom
fix/animated-gif-frames

Conversation

@rsbh

@rsbh rsbh commented Aug 5, 2026

Copy link
Copy Markdown
Member

Animated GIFs in content rendered as still images in dev, server builds, and static builds.

Root cause

sharp() decodes only the first frame unless constructed with { animated: true }. Two independent pipelines flattened animated sources:

  • src/server/api/image.tsoptimizeImage() (dev + server builds). negotiateFormat() also picked AVIF whenever the browser advertised it, which flattens animation even with animated: true.
  • src/cli/commands/static-generate.ts — wrote a single-frame .webp; MDXImage emits <picture><source srcSet={webpUrl(src)} type="image/webp">, so the browser always takes that still WebP and never falls back to the original GIF.

Changes

File Change
src/lib/image-utils.ts new isAnimatable(url) — cheap .gif/.webp extension gate (client-safe, no sharp)
src/lib/image-animation.ts (new) isAnimatedImage(filePath)sharp().metadata().pages > 1, header-only probe; server-only so sharp stays out of the client bundle
src/server/api/image.ts optimizeImage(..., animated)sharp(source, { animated }); negotiateFormat(accept, animated) downgrades AVIF→WebP for animated input; animated flag folded into cache key + ETag; warmupImageCache() wired through
src/cli/commands/static-generate.ts sharp(source, { animated }) for the generated .webp

AVIF is skipped for animated input because sharp flattens animated AVIF output; those requests get animated WebP instead, which every AVIF-capable browser decodes.

The cache key and ETag only gain the animated marker when it is set, so still-image keys stay stable and the on-disk .cache/images survives, while stale single-frame animated entries and pre-fix browser caches are busted (verified: old ETag → 200, new ETag → 304).

Verification

End-to-end with a real 13-frame, 4.7 MB GIF placed in examples/basic:

static build   _content/docs/anim-check.webp  → webp, 13 pages, 464 KB
               _content/docs/anim-check.gif   → gif,  13 pages (fallback)
server build   Accept: avif,webp  → image/webp, 13 pages, 464 KB   (AVIF downgraded)
               Accept: webp       → image/webp, 13 pages
               Accept: png only   → image/gif,  13 pages, 2.0 MB
dev server     Accept: avif,webp  → image/webp, 13 pages
regression     still PNG + avif   → image/avif  (unchanged)

bun test → 264 pass, 0 fail. Added tests: negotiateFormat animated cases, cache-key animated/still stability, isAnimatable units, and optimizeImage frame-count assertions using a sharp-generated 3-frame fixture (no binary committed). Biome clean on touched files; tsc reports no new errors.

🤖 Generated with Claude Code

sharp decodes only the first frame unless constructed with
`{ animated: true }`, so animated GIFs were served as still images by both
image pipelines — the /api/image handler (dev and server builds) and the
static build's .webp generation, which MDXImage always prefers via
<picture><source>.

Detect multi-frame sources with a header-only metadata probe, gated on the
two formats that can animate, and pass the flag through to sharp. AVIF
output is skipped for animated input because sharp flattens it; those
requests get animated WebP instead, which every AVIF-capable browser
decodes. The flag is folded into the cache key and ETag only when set, so
still-image keys stay stable while stale single-frame entries are busted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chronicle Ready Ready Preview Aug 5, 2026 9:22am

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Animated GIF and WebP images now preserve their animation when optimized.
    • Animated images prefer WebP output to prevent flattened, single-frame results.
    • Improved caching and format negotiation for animated image variants.
  • Tests

    • Added coverage for animated image detection, optimization, caching, and format handling.

Walkthrough

The image pipeline now detects animated GIF and WebP sources. It avoids AVIF for animated inputs, preserves frames during Sharp processing, differentiates animated cache entries, and applies the same handling during static generation.

Changes

Animated image support

Layer / File(s) Summary
Image animation detection
packages/chronicle/src/lib/image-utils.ts, packages/chronicle/src/lib/image-animation.ts, packages/chronicle/src/lib/*test.ts
Adds extension-based and metadata-based animation detection. Tests cover animated, still, unsupported, missing, and path-based inputs.
Image API animation handling
packages/chronicle/src/server/api/image.ts, packages/chronicle/src/server/api/image.test.ts
The API selects WebP for animated sources when AVIF is requested. It includes animation status in cache keys and ETags. Sharp preserves frames when requested.
Static image generation
packages/chronicle/src/cli/commands/static-generate.ts
Static WebP generation detects animated sources and passes the animation option to Sharp.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImageRequest
  participant isAnimatedImage
  participant negotiateFormat
  participant Sharp
  ImageRequest->>isAnimatedImage: Inspect source file
  isAnimatedImage-->>ImageRequest: Return animated status
  ImageRequest->>negotiateFormat: Select output format
  negotiateFormat-->>ImageRequest: Return WebP or selected format
  ImageRequest->>Sharp: Optimize with animation option
  Sharp-->>ImageRequest: Return optimized image
Loading

Suggested reviewers: rohilsurana

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preserving all frames when optimizing animated images.
Description check ✅ Passed The description directly explains the animated-image bug, root cause, implementation changes, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/animated-gif-frames

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/chronicle/src/lib/image-animation.ts (1)

2-2: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use configured aliases for changed internal imports.

Replace relative internal imports with the configured @/* aliases.

  • packages/chronicle/src/lib/image-animation.ts#L2-L2: import isAnimatable from @/lib/image-utils.
  • packages/chronicle/src/lib/image-utils.test.ts#L5-L5: change the image-utils module specifier to @/lib/image-utils.
  • packages/chronicle/src/lib/image-animation.test.ts#L6-L6: import isAnimatedImage from @/lib/image-animation.
  • packages/chronicle/src/server/api/image.test.ts#L6-L6: import image API exports from @/server/api/image.

As per coding guidelines, use path alias @/*./src/* configured in tsconfig and vite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/image-animation.ts` at line 2, Update the internal
imports to use the configured `@/` aliases instead of relative paths: in
packages/chronicle/src/lib/image-animation.ts#L2-L2 import isAnimatable from
`@/lib/image-utils`, in packages/chronicle/src/lib/image-utils.test.ts#L5-L5
switch the image-utils module specifier to `@/lib/image-utils`, in
packages/chronicle/src/lib/image-animation.test.ts#L6-L6 import isAnimatedImage
from `@/lib/image-animation`, and in
packages/chronicle/src/server/api/image.test.ts#L6-L6 import the image API
exports from `@/server/api/image`. Keep the exported symbols and test behavior
unchanged while only updating the module specifiers.

Source: Coding guidelines

packages/chronicle/src/lib/image-animation.test.ts (1)

33-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add animated WebP coverage.

These tests validate GIF behavior only. The feature also detects and processes animated WebP inputs. Add one animated WebP fixture and assert metadata detection plus WebP and original-format frame preservation.

  • packages/chronicle/src/lib/image-animation.test.ts#L33-L48: assert isAnimatedImage returns true for a multi-frame WebP.
  • packages/chronicle/src/server/api/image.test.ts#L108-L142: pass that WebP fixture to optimizeImage and assert each animated output retains all pages.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/image-animation.test.ts` around lines 33 - 48, Add
animated WebP coverage at both affected test sites: in
packages/chronicle/src/lib/image-animation.test.ts#L33-L48, extend
isAnimatedImage assertions with a multi-frame WebP fixture and verify it returns
true; in packages/chronicle/src/server/api/image.test.ts#L108-L142, pass the
same WebP fixture through optimizeImage and assert both the WebP output and the
original-format output preserve all pages for animated inputs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/chronicle/src/cli/commands/static-generate.ts`:
- Around line 721-725: Update the output handling around the optimized buffer
write and fallback copy so the original source is copied only when origDest and
destPath differ. Preserve the optimized WebP output when relativePath already
ends in .webp, while retaining the fallback copy behavior for distinct
destinations.

In `@packages/chronicle/src/server/api/image.ts`:
- Around line 119-125: Update buildOptimizedUrl to append the image-pipeline
revision as the v query parameter on generated /api/image URLs, using the
existing revision source rather than changing handler caching. Add or update
coverage to verify a cached pre-fix flattened image is bypassed when the
revision changes and the new response is returned.

---

Nitpick comments:
In `@packages/chronicle/src/lib/image-animation.test.ts`:
- Around line 33-48: Add animated WebP coverage at both affected test sites: in
packages/chronicle/src/lib/image-animation.test.ts#L33-L48, extend
isAnimatedImage assertions with a multi-frame WebP fixture and verify it returns
true; in packages/chronicle/src/server/api/image.test.ts#L108-L142, pass the
same WebP fixture through optimizeImage and assert both the WebP output and the
original-format output preserve all pages for animated inputs.

In `@packages/chronicle/src/lib/image-animation.ts`:
- Line 2: Update the internal imports to use the configured `@/` aliases instead
of relative paths: in packages/chronicle/src/lib/image-animation.ts#L2-L2 import
isAnimatable from `@/lib/image-utils`, in
packages/chronicle/src/lib/image-utils.test.ts#L5-L5 switch the image-utils
module specifier to `@/lib/image-utils`, in
packages/chronicle/src/lib/image-animation.test.ts#L6-L6 import isAnimatedImage
from `@/lib/image-animation`, and in
packages/chronicle/src/server/api/image.test.ts#L6-L6 import the image API
exports from `@/server/api/image`. Keep the exported symbols and test behavior
unchanged while only updating the module specifiers.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 81a6d7b0-a304-4208-9870-385fb0da7dd7

📥 Commits

Reviewing files that changed from the base of the PR and between a05f7a4 and 56fedca.

📒 Files selected for processing (7)
  • packages/chronicle/src/cli/commands/static-generate.ts
  • packages/chronicle/src/lib/image-animation.test.ts
  • packages/chronicle/src/lib/image-animation.ts
  • packages/chronicle/src/lib/image-utils.test.ts
  • packages/chronicle/src/lib/image-utils.ts
  • packages/chronicle/src/server/api/image.test.ts
  • packages/chronicle/src/server/api/image.ts

Comment on lines +721 to +725
// Animated sources must keep every frame — the <picture> element in
// MDXImage always prefers this .webp, so a flattened one would render
// an animated GIF as a still image
const animated = await isAnimatedImage(srcPath);
const optimizedBuf = await sharp(source, { animated })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not overwrite the optimized WebP output.

When relativePath already ends in .webp, webpRelative at Line 715 equals relativePath. destPath and origDest then point to the same file. Line 729 writes the optimized buffer, but Line 734 copies the original source over it. This bypasses resizing and quality settings for animated WebP inputs.

Skip the fallback copy when origDest === destPath.

Proposed fix
         const origDest = path.join(outputDir, '_content', relativePath);
-        await fs.mkdir(path.dirname(origDest), { recursive: true });
-        await fs.copyFile(srcPath, origDest);
+        if (origDest !== destPath) {
+          await fs.mkdir(path.dirname(origDest), { recursive: true });
+          await fs.copyFile(srcPath, origDest);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/cli/commands/static-generate.ts` around lines 721 -
725, Update the output handling around the optimized buffer write and fallback
copy so the original source is copied only when origDest and destPath differ.
Preserve the optimized WebP output when relativePath already ends in .webp,
while retaining the fallback copy behavior for distinct destinations.

Comment on lines 119 to +125
const requestedVersion = event.url.searchParams.get('v')
const cacheControl = import.meta.dev
? REVALIDATE_CACHE
: assetCacheControl(requestedVersion, currentVersion)
const etag = etagFor(currentVersion ?? String(stat.mtimeMs), String(w), String(q), format)
// `animated` is part of the ETag so browsers holding a pre-fix single-frame
// response revalidate instead of getting a 304
const etag = etagFor(currentVersion ?? String(stat.mtimeMs), String(w), String(q), format, ...(animated ? ['animated'] : []))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

ast-grep outline packages/chronicle/src/server/utils/asset-cache.ts --items all
sed -n '1,100p' packages/chronicle/src/server/utils/asset-cache.ts

ast-grep outline packages/chronicle/src/lib/image-utils.ts --items all
rg -n -C 4 'buildOptimizedUrl|assetCacheControl|IMMUTABLE_CACHE' packages/chronicle/src

Repository: raystack/chronicle

Length of output: 21607


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '--- packages/chronicle/src/server/api/image.ts ---\n'
wc -l packages/chronicle/src/server/api/image.ts
sed -n '1,180p' packages/chronicle/src/server/api/image.ts

printf '\n--- getAssetVersion usages ---\n'
rg -n -C 3 'getAssetVersion|version' packages/chronicle/src/lib packages/chronicle/src/server | sed -n '1,220p'

printf '\n--- package/tooling version context ---\n'
sed -n '1,120p' package.json
fd -e toml -e json -e lock -e lockfile -e yarn.lock -e pnpm-lock.yaml . | sed -n '1,80p'

Repository: raystack/chronicle

Length of output: 247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat <<'EOF'
--- packages/chronicle/src/server/api/image.ts ---
EOF
wc -l packages/chronicle/src/server/api/image.ts
sed -n '1,180p' packages/chronicle/src/server/api/image.ts

printf '%s\n' ''
printf '%s\n' '--- getAssetVersion usages ---'
rg -n -C 3 'getAssetVersion|version' packages/chronicle/src/lib packages/chronicle/src/server | sed -n '1,220p'

printf '%s\n' ''
printf '%s\n' '--- package/tooling version context ---'
sed -n '1,120p' package.json
fd -e toml -e json -e lock -e lockfile -e yarn.lock -e pnpm-lock.yaml . | sed -n '1,80p'

Repository: raystack/chronicle

Length of output: 25063


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- asset-version source/usages ---'
fd -a 'asset-version' packages/chronicle/src
sed -n '1,180p' packages/chronicle/src/lib/asset-version.ts

printf '%s\n' ''
printf '%s\n' '--- version/currentVersion search ---'
rg -n -C 4 'currentVersion|RECOMMENDED_IMAGE_PIPELINE_VERSION|IMAGE.*VERSION|pipeline|version' packages/chronicle/src packages/chronicle/package.json | sed -n '1,260p'

printf '%s\n' ''
printf '%s\n' '--- precise image handler cache paths ---'
sed -n '88,132p' packages/chronicle/src/server/api/image.ts

printf '%s\n' ''
printf '%s\n' '--- image URL call sites and version passing ---'
python3 - <<'PY'
from pathlib import Path
for p in Path('packages/chronicle/src').rglob('*'):
    if p.is_file() and p.name.endswith(('.ts', '.tsx')):
        text = p.read_text(errors='ignore')
        if 'buildOptimizedUrl' in text:
            for i,l in enumerate(text.splitlines(), 1):
                if 'buildOptimizedUrl' in l:
                    print(f'{p}:{i}:{l.strip()}')
PY

Repository: raystack/chronicle

Length of output: 27532


Invalidate image pipeline revisions in the URL.

buildOptimizedUrl currently omits ?v, so browsers can cache a pre-fix flattened image as public, max-age=31536000, immutable and never hit the handler to see the new ETag. Include an image-pipeline revision in generated /api/image URLs, and test a pre-fix cache hit does not reuse the old response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/server/api/image.ts` around lines 119 - 125, Update
buildOptimizedUrl to append the image-pipeline revision as the v query parameter
on generated /api/image URLs, using the existing revision source rather than
changing handler caching. Add or update coverage to verify a cached pre-fix
flattened image is bypassed when the revision changes and the new response is
returned.

@rsbh
rsbh requested a review from rohilsurana August 5, 2026 09:38

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Fixes animated GIFs (and animated WebP) rendering as still images. sharp only decodes the first frame unless built with { animated: true }, and two pipelines flattened animated sources: the /api/image handler (dev + server) and the static build. The fix adds a cheap isAnimatable() extension gate, a server-only isAnimatedImage() probe, threads an animated flag through optimizeImage, negotiateFormat, the cache key, and the ETag, and downgrades AVIF to WebP for animated input.

What looks good

  • The AVIF downgrade is correct. Every AVIF-capable browser also decodes WebP, so returning WebP for an AVIF-only Accept is safe.
  • The cache key and ETag only gain the animated marker when it is set. Still-image keys stay stable, so the on-disk cache and browser caches survive, while stale single-frame animated entries get busted.
  • sharp stays out of the client bundle by keeping the probe in image-animation.ts and the plain extension gate in image-utils.ts. Nice split.
  • Static build writes both the animated .webp and copies the original as the <picture> fallback, so the fix holds even with JS off.
  • Solid test coverage: negotiateFormat animated cases, cache-key stability, isAnimatable units, and real frame-count assertions on a generated fixture (no binary committed).

Notes

Only nit-level items, none blocking. Left two inline. Approving.

// metadata probe in isAnimatedImage()
export function isAnimatable(url: string): boolean {
const base = url.split('?')[0].toLowerCase();
return base.endsWith('.gif') || base.endsWith('.webp');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this gate covers .gif and .webp only. Animated PNG (APNG) and animated AVIF also carry multiple pages, so an APNG dropped into content would still get flattened. Adding .png here would make every PNG pay the metadata probe, so leaving it out is a fair tradeoff. A one-line comment noting APNG is intentionally out of scope would stop the next person from treating it as a bug.

throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
}

const animated = await isAnimatedImage(filePath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: isAnimatedImage now runs on every request before the cache lookup. It is a header-only sharp().metadata() read, and isAnimatable short-circuits png/jpg without touching sharp, so the extra cost lands only on .gif/.webp. Fine at docs traffic, but note that cached hits for those two formats now pay one extra file open plus header parse each time. No change needed, just flagging the added work on the hot path.

@rsbh
rsbh merged commit d8c82d5 into main Aug 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants