-
Notifications
You must be signed in to change notification settings - Fork 1
fix: keep every frame when optimizing animated images #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; | ||
| import fs from 'node:fs/promises'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import sharp from 'sharp'; | ||
| import { isAnimatedImage } from './image-animation'; | ||
|
|
||
| let dir: string; | ||
| let animatedGif: string; | ||
| let stillGif: string; | ||
| let stillPng: string; | ||
|
|
||
| async function solidPng(color: string): Promise<Buffer> { | ||
| return sharp({ create: { width: 8, height: 8, channels: 3, background: color } }).png().toBuffer(); | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chronicle-anim-')); | ||
| animatedGif = path.join(dir, 'tour.gif'); | ||
| stillGif = path.join(dir, 'still.gif'); | ||
| stillPng = path.join(dir, 'photo.png'); | ||
|
|
||
| const frames = await Promise.all([solidPng('#f00'), solidPng('#00f'), solidPng('#0f0')]); | ||
| await fs.writeFile(animatedGif, await sharp(frames, { join: { animated: true } }).gif().toBuffer()); | ||
| await fs.writeFile(stillGif, await sharp(frames[0]).gif().toBuffer()); | ||
| await fs.writeFile(stillPng, frames[0]); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await fs.rm(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('isAnimatedImage', () => { | ||
| test('returns true for a multi-frame gif', async () => { | ||
| expect(await isAnimatedImage(animatedGif)).toBe(true); | ||
| }); | ||
|
|
||
| test('returns false for a single-frame gif', async () => { | ||
| expect(await isAnimatedImage(stillGif)).toBe(false); | ||
| }); | ||
|
|
||
| test('returns false for formats that cannot animate', async () => { | ||
| expect(await isAnimatedImage(stillPng)).toBe(false); | ||
| }); | ||
|
|
||
| test('returns false for a missing file', async () => { | ||
| expect(await isAnimatedImage(path.join(dir, 'nope.gif'))).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import sharp from 'sharp'; | ||
| import { isAnimatable } from './image-utils'; | ||
|
|
||
| // Server-only: keeps the `sharp` import out of image-utils.ts, which is shared | ||
| // with the client bundle. | ||
|
|
||
| /** | ||
| * True when the file holds more than one frame. sharp decodes only the first | ||
| * frame unless constructed with `{ animated: true }`, so callers need this to | ||
| * avoid flattening animated GIF/WebP sources. | ||
| */ | ||
| export async function isAnimatedImage(filePath: string): Promise<boolean> { | ||
| if (!isAnimatable(filePath)) return false; | ||
| try { | ||
| const { pages } = await sharp(filePath).metadata(); | ||
| return (pages ?? 1) > 1; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,13 @@ export function isSvg(url: string): boolean { | |
| return url.split('?')[0].endsWith('.svg'); | ||
| } | ||
|
|
||
| // Only these formats can hold multiple frames — everything else skips the | ||
| // metadata probe in isAnimatedImage() | ||
| export function isAnimatable(url: string): boolean { | ||
| const base = url.split('?')[0].toLowerCase(); | ||
| return base.endsWith('.gif') || base.endsWith('.webp'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this gate covers |
||
| } | ||
|
|
||
| export function buildOptimizedUrl(url: string, width: number, quality = DEFAULT_QUALITY, version?: string): string { | ||
| const base = `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`; | ||
| return version ? `${base}&v=${version}` : base; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,16 +9,22 @@ import { safePath } from '@/server/utils/safe-path' | |
| import { assetCacheControl, etagFor, isNotModified, REVALIDATE_CACHE } from '@/server/utils/asset-cache' | ||
| import { getAssetVersion } from '@/lib/asset-version' | ||
| import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils' | ||
| import { isAnimatedImage } from '@/lib/image-animation' | ||
|
|
||
| export const STORAGE_KEY = 'image-cache' | ||
|
|
||
| const inflight = new Map<string, Promise<Buffer>>() | ||
|
|
||
| export type OutputFormat = 'avif' | 'webp' | 'original' | ||
|
|
||
| export function negotiateFormat(accept: string | null): OutputFormat { | ||
| if (accept?.includes('image/avif')) return 'avif' | ||
| if (accept?.includes('image/webp')) return 'webp' | ||
| export function negotiateFormat(accept: string | null, animated = false): OutputFormat { | ||
| const wantsAvif = accept?.includes('image/avif') ?? false | ||
| const wantsWebp = accept?.includes('image/webp') ?? false | ||
| // sharp flattens animated sources to a single frame on AVIF output, so | ||
| // animated images fall back to WebP — every AVIF-capable browser decodes | ||
| // animated WebP | ||
| if (wantsAvif && !animated) return 'avif' | ||
| if (wantsWebp || (animated && wantsAvif)) return 'webp' | ||
| return 'original' | ||
| } | ||
|
|
||
|
|
@@ -30,8 +36,11 @@ export const MIME: Record<string, string> = { | |
| '.webp': 'image/webp', | ||
| } | ||
|
|
||
| export function cacheKey(url: string, w: number, q: number, format: OutputFormat, version?: string | number): string { | ||
| const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${version ?? 0}`).digest('hex').slice(0, 16) | ||
| export function cacheKey(url: string, w: number, q: number, format: OutputFormat, version?: string | number, animated = false): string { | ||
| // The animated marker is only appended when set, so keys for still images | ||
| // stay stable across the animation fix and the on-disk cache survives | ||
| const suffix = animated ? ':animated' : '' | ||
| const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${version ?? 0}${suffix}`).digest('hex').slice(0, 16) | ||
| return `${hash}.${format}` | ||
| } | ||
|
|
||
|
|
@@ -48,9 +57,10 @@ export async function optimizeImage( | |
| w: number, | ||
| q: number, | ||
| format: OutputFormat, | ||
| animated = false, | ||
| ): Promise<Buffer> { | ||
| const source = await fs.readFile(filePath); | ||
| const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true }); | ||
| const pipeline = sharp(source, { animated }).resize({ width: w, withoutEnlargement: true }); | ||
| if (format === 'avif') return pipeline.avif({ quality: q }).toBuffer(); | ||
| if (format === 'webp') return pipeline.webp({ quality: q }).toBuffer(); | ||
| return pipeline.toBuffer(); | ||
|
|
@@ -91,24 +101,28 @@ export default defineHandler(async event => { | |
| throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' }) | ||
| } | ||
|
|
||
| const stat = await fs.stat(filePath).catch(() => null) | ||
| if (!stat) { | ||
| throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' }) | ||
| } | ||
|
|
||
| const animated = await isAnimatedImage(filePath) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| const accept = event.headers.get('accept') | ||
| const format = negotiateFormat(accept) | ||
| const format = negotiateFormat(accept, animated) | ||
| const ext = path.extname(filePath).toLowerCase() | ||
| const originalMime = MIME[ext] ?? 'application/octet-stream' | ||
| const contentType = format === 'original' ? originalMime : `image/${format}` | ||
|
|
||
| const stat = await fs.stat(filePath).catch(() => null) | ||
| if (!stat) { | ||
| throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' }) | ||
| } | ||
| const currentVersion = await getAssetVersion(filePath) | ||
| const key = cacheKey(url, w, q, format, currentVersion ?? stat.mtimeMs) | ||
| const key = cacheKey(url, w, q, format, currentVersion ?? stat.mtimeMs, animated) | ||
|
|
||
| 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'] : [])) | ||
|
Comment on lines
119
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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()}')
PYRepository: raystack/chronicle Length of output: 27532 Invalidate image pipeline revisions in the URL.
🤖 Prompt for AI Agents |
||
| const headers = { | ||
| 'Content-Type': contentType, | ||
| 'Cache-Control': cacheControl, | ||
|
|
@@ -132,7 +146,7 @@ export default defineHandler(async event => { | |
| } | ||
|
|
||
| const work = (async () => { | ||
| const optimized = await optimizeImage(filePath, w, q, format) | ||
| const optimized = await optimizeImage(filePath, w, q, format, animated) | ||
| await storage.setItemRaw(key, optimized) | ||
| return optimized | ||
| })() | ||
|
|
@@ -183,12 +197,13 @@ export async function warmupImageCache() { | |
| const stat = await fs.stat(filePath).catch(() => null); | ||
| if (!stat) continue; | ||
|
|
||
| const key = cacheKey(base, w, q, format, (await getAssetVersion(filePath)) ?? stat.mtimeMs); | ||
| const animated = await isAnimatedImage(filePath); | ||
| const key = cacheKey(base, w, q, format, (await getAssetVersion(filePath)) ?? stat.mtimeMs, animated); | ||
| const cached = await storage.getItemRaw(key); | ||
| if (cached) continue; | ||
|
|
||
| try { | ||
| const optimized = await optimizeImage(filePath, w, q, format); | ||
| const optimized = await optimizeImage(filePath, w, q, format, animated); | ||
| await storage.setItemRaw(key, optimized); | ||
| warmed++; | ||
| } catch { /* skip unprocessable */ } | ||
|
|
||
There was a problem hiding this comment.
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
relativePathalready ends in.webp,webpRelativeat Line 715 equalsrelativePath.destPathandorigDestthen 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
🤖 Prompt for AI Agents